diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1102867 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,55 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +CFSync 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. + +## 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. `fluidd-panel.js` is a standalone script injected into the Fluidd UI via bookmarklet or Tampermonkey userscript (generated from the settings page). + +**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). Printer identity (`printer_name`, `printer_firmware`) is parsed from the Moonraker WebSocket. + +## 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 or auto-linked by RFID tag (`_spoolman_autolink_by_rfid()`). 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 (`_spoolman_*` helpers) and never block local tracking. + +**Spoolman API endpoints:** `GET /api/ui/spoolman/spools`, `POST /api/ui/spoolman/link`, `POST /api/ui/spoolman/unlink`, `GET /api/ui/spoolman/spool_detail`. + +**Percentage calculation:** For RFID-linked spools, remaining % is calculated the same way as manual spools — using Spoolman's `remaining_weight` divided by the spool's initial weight. + +## 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/README.md b/README.md index e836858..5e47fcb 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,137 @@ -# Filament-Management +# CFSync -Local filament / spool tracking for Creality CFS & Klipper via Moonraker. +A local web dashboard for managing filament spools on **Creality K1 series printers with CFS** (Colour Filament System), including the K1, K1C, K1 Max, K1 SE, and K2 Plus. CFSync connects directly to one or more printers over WebSocket, reads live spool data from all CFS slots, and optionally syncs consumption back to [Spoolman](https://github.com/Donkie/Spoolman). -Track filament usage per slot, handle color changes during prints and keep everything fully local. +![CFSync screenshot](docs/screenshot.png) -No cloud. No external services. +## Features ---- +- Multi-printer support — monitor and manage multiple configured printers in one UI +- Live CFS slot view — filament colour, material, and fill level per slot +- RFID spool percent from printer sensor; calculated percent for non-RFID spools via Spoolman +- Spoolman integration — link spools, track remaining weight, auto-report usage at job end +- Moonraker job tracking — attributes `filament_used` proportionally across active slots at print completion +- Clickable CFS box temperature/humidity values with historical graphs +- Recent jobs panel with per-slot filament usage breakdown +- Reallocate historical job usage to a different Spoolman spool (link/relink) +- Printer name and firmware version shown in header (read from WebSocket) +- Dark UI, no build step, runs as a systemd service -## 🚀 Installation (One-Liner) +## Screenshots + +### Dashboard + +![CFSync dashboard](docs/screenshot.png) + +### Recent jobs + +![Recent jobs](docs/screenshot-past-jobs.png) + +### Temperature history graph + +![Temperature history graph](docs/screenshot-temperature-graph.png) + +### Humidity history graph + +![Humidity history graph](docs/screenshot-humidity-graph.png) + +### Reallocate filament usage + +![Reallocate filament usage](docs/screenshot-reallocate-filament-usage.png) + +## Requirements + +- Linux host on the same network as the printer (e.g. a Pi or the printer's companion board) +- Creality K1 series printer with CFS (K1, K1C, K1 Max, K1 SE, K2 Plus) +- Python 3.10+ +- Optional: [Spoolman](https://github.com/Donkie/Spoolman) for spool tracking + +## Install ```bash -curl -fsSL https://raw.githubusercontent.com/jkef80/Filament-Management/main/install.sh | sudo bash +curl -fsSL https://raw.githubusercontent.com/koen01/CFSync/refs/heads/spoolman/install.sh | sudo bash +``` + +The installer will prompt for: + +| Prompt | Example | +|---|---| +| UI port | `8005` | +| Printer IPs | `192.168.1.144, 192.168.1.145` | +| Spoolman URL *(optional)* | `http://192.168.1.10:7912` | + +After install, open `http://:` in your browser. + +## Configuration + +Settings are stored in `data/config.json`: + +```json +{ + "printers": [ + { "id": "Creality k2 Pro", "address": "192.168.1.144" }, + { "id": "Creality Hi", "address": "192.168.1.145" } + ], + "filament_diameter_mm": 1.75, + "spoolman_url": "http://192.168.1.10:7912" +} +``` + +Alternative (IDs default to IP address): + +```json +{ + "printer_urls": ["192.168.1.144", "192.168.1.145"], + "filament_diameter_mm": 1.75, + "spoolman_url": "http://192.168.1.10:7912" +} +``` + +## Spoolman — RFID auto-linking + +When an RFID-tagged spool is inserted into a CFS slot, CFSync automatically links it to the correct Spoolman spool — no manual selection needed. There are two mechanisms, used together: + +**1. Serial number via SSH (primary — works instantly with CFTag-tagged spools)** + +CFTag writes the Spoolman spool ID directly onto the RFID chip as its serial number. When CFSync detects a new RFID spool, it SSHes into the printer and reads the spool data file to extract the serial number. If it matches a Spoolman spool ID, the slot is linked immediately — no prior setup or manual linking required. + +**2. RFID code via Spoolman extra field (fallback)** + +When you manually link a spool via the CFSync slot modal, CFSync stores the slot's RFID code in a `cfs_rfid` extra field on that spool in Spoolman. Next time the same tag is detected in any slot, CFSync looks it up and auto-links. This requires the extra field to be pre-created in Spoolman: + +1. Open Spoolman → **Settings** → **Extra fields** +2. Add a new field: **Name** `cfs_rfid`, **Field type** Text +3. Save + +![Spoolman link modal](docs/spoolman-link.png) + +> **Note:** RFID tags are only present on spools with a Creality RFID chip. Spools without RFID can still be linked manually. + +## Workflow — adding a new spool with RFID + +The recommended flow uses **[CFTag](https://github.com/koen01/cftag)** (Android, NFC required) — a companion app built for this ecosystem. CFTag handles the entire tagging process in one session: it creates the spool in Spoolman, then guides you through writing both RFID tags on the spool back-to-back without re-entering any data. + +1. **Open CFTag** → fill in filament details → tap **Create in Spoolman**. CFTag creates the spool entry and immediately prompts you to write the first tag. Hold your phone to the tag, then flip the spool and write the second tag when prompted — done in one flow. +2. **Load the spool** into a CFS slot. +3. **CFSync auto-links** the slot to the Spoolman spool the moment it detects the RFID tag — no manual action needed. + +From this point on, inserting that spool into any CFS slot will auto-link it instantly. Filament consumption is reported back to Spoolman after each print. + +> Spools without a Creality RFID chip skip step 1 and must be linked manually each time they are loaded. + +## Update + +```bash +curl -fsSL https://raw.githubusercontent.com/koen01/CFSync/refs/heads/spoolman/update.sh | sudo bash +``` + +## Logs + +```bash +sudo journalctl -u filament-management -f +``` + +## Credits + +- [jkef80/Filament-Management](https://github.com/jkef80/Filament-Management) — original Moonraker-based filament management that this project evolved from +- [DaviBe92/k2-websocket-re](https://github.com/DaviBe92/k2-websocket-re) — reverse-engineered Creality K2 WebSocket protocol documentation that made the CFS integration possible diff --git a/docs/screenshot-humidity-graph.png b/docs/screenshot-humidity-graph.png new file mode 100644 index 0000000..8743f6f Binary files /dev/null and b/docs/screenshot-humidity-graph.png differ diff --git a/docs/screenshot-past-jobs.png b/docs/screenshot-past-jobs.png new file mode 100644 index 0000000..b0f4415 Binary files /dev/null and b/docs/screenshot-past-jobs.png differ diff --git a/docs/screenshot-reallocate-filament-usage.png b/docs/screenshot-reallocate-filament-usage.png new file mode 100644 index 0000000..c9e6cb7 Binary files /dev/null and b/docs/screenshot-reallocate-filament-usage.png differ diff --git a/docs/screenshot-temperature-graph.png b/docs/screenshot-temperature-graph.png new file mode 100644 index 0000000..d811917 Binary files /dev/null and b/docs/screenshot-temperature-graph.png differ diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000..f09b128 Binary files /dev/null and b/docs/screenshot.png differ diff --git a/docs/spoolman-link.png b/docs/spoolman-link.png new file mode 100644 index 0000000..65889e6 Binary files /dev/null and b/docs/spoolman-link.png differ diff --git a/install.sh b/install.sh index 4ab4d5e..b928333 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/davidkinnes/CFSync.git" if [[ ${EUID} -ne 0 ]]; then echo "Please run with sudo" @@ -28,17 +28,19 @@ ask() { echo "${var:-$default}" } -echo "=== Filament Management Installer ===" +echo "=== CFSync Installer ===" UI_PORT=$(ask "UI Port" "8005") -MOON_HOST=$(ask "Moonraker Host/IP" "192.168.178.148") -MOON_PORT=$(ask "Moonraker Port" "7125") -POLL=$(ask "Poll interval (sec)" "5") +PRINTER_IPS=$(ask "Printer IPs (comma-separated)" "192.168.1.144") 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 +PRINTER_JSON=$(echo "$PRINTER_IPS" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | awk 'NF' | awk '{printf "\"%s\",", $0}' | sed 's/,$//') +if [ -n "$PRINTER_JSON" ]; then + PRINTER_JSON="[$PRINTER_JSON]" +else + PRINTER_JSON="[]" +fi echo "Installing to $APP_DIR" @@ -73,10 +75,9 @@ mkdir -p "$APP_DIR/data" cat > "$APP_DIR/data/config.json" < "/etc/systemd/system/${SERVICE_NAME}.service" < dict: return obj.dict(exclude_unset=exclude_unset) +def _write_json_atomic(path: Path, data: dict) -> None: + """Write JSON atomically to avoid truncated files on interruption.""" + tmp = path.with_name(f".{path.name}.tmp") + tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False)) + tmp.replace(path) + + APP_DIR = Path(__file__).resolve().parent DATA_DIR = APP_DIR / "data" STATIC_DIR = APP_DIR / "static" STATE_PATH = DATA_DIR / "state.json" PROFILES_PATH = DATA_DIR / "profiles.json" CONFIG_PATH = DATA_DIR / "config.json" +JOB_DB_PATH = DATA_DIR / "jobs.sqlite3" DEFAULT_SLOTS = [ "1A", "1B", "1C", "1D", @@ -72,6 +83,19 @@ def _req_dump(obj, *, exclude_unset: bool = False) -> dict: "3A", "3B", "3C", "3D", "4A", "4B", "4C", "4D", ] +PRINTER_SPOOL_SLOT = "SP" + +# CFS box environment sampling and retention policy. +# Keep recent data at high resolution, but compact older data so state.json +# remains small even with month-long history. +_CFS_ENV_MIN_SAMPLE_INTERVAL = 60.0 +_CFS_ENV_RETENTION_SECS = 30 * 24 * 3600 +_CFS_ENV_BUCKET_24H_SECS = 60.0 +_CFS_ENV_BUCKET_7D_SECS = 10 * 60.0 +_CFS_ENV_BUCKET_30D_SECS = 60 * 60.0 +_CFS_ENV_MAX_POINTS = 4096 +_CFS_ENV_TEMP_DELTA = 0.2 +_CFS_ENV_HUMIDITY_DELTA = 1.0 def _now() -> float: @@ -92,6 +116,77 @@ def _parse_iso_ts(val: str) -> Optional[float]: return None +def _as_finite_float_or_none(value) -> Optional[float]: + try: + vv = float(value) + except Exception: + return None + return vv if math.isfinite(vv) else None + + +def _coerce_cfs_env_sample(sample) -> Optional[dict]: + if isinstance(sample, dict): + ts = _as_finite_float_or_none(sample.get("ts")) + t = _as_finite_float_or_none(sample.get("temperature_c")) + h = _as_finite_float_or_none(sample.get("humidity_pct")) + else: + ts = _as_finite_float_or_none(getattr(sample, "ts", None)) + t = _as_finite_float_or_none(getattr(sample, "temperature_c", None)) + h = _as_finite_float_or_none(getattr(sample, "humidity_pct", None)) + if not ts or ts <= 0: + return None + if t is None and h is None: + return None + out = {"ts": ts} + if t is not None: + out["temperature_c"] = t + if h is not None: + out["humidity_pct"] = h + return out + + +def _compact_cfs_env_history(samples_in: list, now_ts: float) -> list[dict]: + if not samples_in: + return [] + + cutoff = now_ts - _CFS_ENV_RETENTION_SECS + clean: list[dict] = [] + for item in samples_in: + coerced = _coerce_cfs_env_sample(item) + if not coerced: + continue + ts = _as_finite_float_or_none(coerced.get("ts")) or 0.0 + if ts < cutoff: + continue + clean.append(coerced) + if not clean: + return [] + + clean.sort(key=lambda x: float(x.get("ts") or 0.0)) + out: list[dict] = [] + last_key = None + for sample in clean: + ts = _as_finite_float_or_none(sample.get("ts")) or 0.0 + age = max(0.0, now_ts - ts) + if age <= 24 * 3600: + bucket_span = _CFS_ENV_BUCKET_24H_SECS + elif age <= 7 * 24 * 3600: + bucket_span = _CFS_ENV_BUCKET_7D_SECS + else: + bucket_span = _CFS_ENV_BUCKET_30D_SECS + key = (int(bucket_span), int(ts // bucket_span)) + if out and key == last_key: + # Keep latest sample in each time bucket. + out[-1] = sample + else: + out.append(sample) + last_key = key + + if len(out) > _CFS_ENV_MAX_POINTS: + out = out[-_CFS_ENV_MAX_POINTS:] + return out + + def _ensure_data_files() -> None: DATA_DIR.mkdir(parents=True, exist_ok=True) STATIC_DIR.mkdir(parents=True, exist_ok=True) @@ -118,14 +213,14 @@ def _ensure_data_files() -> None: CONFIG_PATH.write_text( json.dumps( { - # Optional: set this to enable automatic job usage reading from Moonraker - # Example: "http://192.168.178.148:7125" - "moonraker_url": "", - "poll_interval_sec": 5, + # Hostname or IPs of printers (used for WebSocket connection at ws://host:9999) + # Example: ["192.168.178.148", "192.168.178.149"] + "printer_urls": [], # Filament diameter used for mm->g conversion "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, @@ -133,34 +228,11 @@ def _ensure_data_files() -> None: ) if not STATE_PATH.exists(): - slots: Dict[str, dict] = {} - for s in DEFAULT_SLOTS: - slots[s] = _model_dump(SlotState(slot=s)) state = { - "active_slot": "2A", - "auto_mode": False, - "slots": slots, - "current_job": "", - "current_job_filament_mm": 0, - "current_job_filament_g": 0.0, - "last_accounted_job_mm": 0, - "last_accounted_slot": None, - # per-slot usage history (newest first) - "slot_history": {}, - # in-flight job attribution (persisted so a restart doesn't lose the active print) - "job_track_name": "", - "job_track_started_at": 0.0, - "job_track_last_mm": 0, - "job_track_slot_mm": {}, - "job_track_slot_g": {}, - "job_track_last_state": "", - # snapshot from Moonraker history (global list) - "moonraker_history": [], - # local manual allocations for Moonraker history -> slots - "moonraker_allocations": {}, + "printers": {}, "updated_at": _now(), } - STATE_PATH.write_text(json.dumps(state, indent=2, ensure_ascii=False)) + _write_json_atomic(STATE_PATH, state) def load_profiles() -> dict: @@ -171,21 +243,148 @@ def load_profiles() -> dict: return {} +def _normalize_printer_host(raw: str) -> str: + raw = (raw or "").strip() + if not raw: + return "" + if "://" in raw: + host = urlparse(raw).hostname or "" + return host.strip() if host else "" + # strip path/port if user pasted host:port or host/path + host = raw.split("/")[0].strip() + if ":" in host: + host = host.split(":", 1)[0].strip() + return host + + +def _normalize_printer_id(raw_id: str, address: str) -> str: + rid = (raw_id or "").strip() + if rid: + return rid + return (address or "").strip() + + +def _dedupe_printers(items: List[str]) -> List[str]: + seen = set() + out: List[str] = [] + for it in items: + if not it or it in seen: + continue + seen.add(it) + out.append(it) + return out + + def load_config() -> dict: _ensure_data_files() try: - return json.loads(CONFIG_PATH.read_text()) + cfg = json.loads(CONFIG_PATH.read_text()) except Exception: - return { - "moonraker_url": "", - "poll_interval_sec": 5, - "filament_diameter_mm": 1.75, - "cfs_autosync": False, - } + cfg = {} + + printer_urls: List[str] = [] + printers: List[dict] = [] + + # Backward compat: migrate legacy printer_url into printer_urls + legacy_printer = _normalize_printer_host(cfg.get("printer_url") or "") + if legacy_printer: + printer_urls.append(legacy_printer) + + # Backward compat: extract hostname from legacy moonraker_url if no printer provided + if not printer_urls: + mu = (cfg.get("moonraker_url") or "").strip() + if mu: + host = urlparse(mu).hostname or "" + if host: + print(f"[CONFIG] Migrating moonraker_url → printer_urls (host={host!r})") + printer_urls.append(host) + + # Preferred multi-printer config + raw_list = cfg.get("printer_urls") + if isinstance(raw_list, list): + for raw in raw_list: + host = _normalize_printer_host(str(raw)) + if host: + printer_urls.append(host) + + # Backward/alternate compat: "printers": [{"address": "..."}] + raw_printers = cfg.get("printers") + if isinstance(raw_printers, list): + for item in raw_printers: + host = "" + if isinstance(item, dict): + host = ( + item.get("address") + or item.get("host") + or item.get("ip") + or item.get("url") + or item.get("printer_url") + or "" + ) + elif isinstance(item, str): + host = item + host = _normalize_printer_host(str(host)) + if host: + printer_urls.append(host) + + # Backward/alternate compat: "printers": [{"id": "...", "address": "..."}] + raw_printers = cfg.get("printers") + if isinstance(raw_printers, list): + for item in raw_printers: + host = "" + pid = "" + if isinstance(item, dict): + host = ( + item.get("address") + or item.get("host") + or item.get("ip") + or item.get("url") + or item.get("printer_url") + or "" + ) + pid = ( + item.get("id") + or item.get("name") + or item.get("label") + or "" + ) + elif isinstance(item, str): + host = item + host = _normalize_printer_host(str(host)) + if not host: + continue + pid = _normalize_printer_id(str(pid), host) + printers.append({"id": pid, "address": host}) + printer_urls.append(host) + + # Also promote plain printer_urls into printers (id defaults to address) + existing_addrs = {str(p.get("address") or "").strip() for p in printers} + for host in printer_urls: + if host in existing_addrs: + continue + pid = _normalize_printer_id("", host) + printers.append({"id": pid, "address": host}) + + # Dedupe by id (keep first) + seen_ids = set() + printers_out: List[dict] = [] + for p in printers: + pid = str(p.get("id") or "").strip() + addr = str(p.get("address") or "").strip() + if not pid or not addr or pid in seen_ids: + continue + seen_ids.add(pid) + printers_out.append({"id": pid, "address": addr}) + + cfg["printers"] = printers_out + cfg["printer_urls"] = _dedupe_printers(printer_urls) + cfg.setdefault("filament_diameter_mm", 1.75) + cfg.setdefault("spoolman_url", "") + return cfg -def _migrate_state_dict(data: dict) -> dict: - """Make state.json tolerant to older/hand-edited formats.""" +def _migrate_app_state_dict(data: dict) -> dict: + """Make a single-printer AppState tolerant to older/hand-edited formats.""" if not isinstance(data, dict): return data @@ -207,13 +406,6 @@ def _migrate_state_dict(data: dict) -> dict: except Exception: data["updated_at"] = 0.0 - # Ensure new fields exist - data.setdefault("current_job", data.get("job", {}).get("name", "")) - data.setdefault("current_job_filament_mm", int(data.get("job", {}).get("used_mm", 0) or 0)) - data.setdefault("current_job_filament_g", float(data.get("job", {}).get("used_g", 0.0) or 0.0)) - data.setdefault("last_accounted_job_mm", int(data.get("last_accounted_job_mm", 0) or 0)) - data.setdefault("last_accounted_slot", data.get("last_accounted_slot")) - # Slots: allow keys like "2A": {material,color,...} without slot field slots = data.get("slots", {}) or {} if isinstance(slots, dict): @@ -231,12 +423,8 @@ def _migrate_state_dict(data: dict) -> dict: mat = sd.get("material") if isinstance(mat, str) and mat.strip() in ("", "-", "—", "–"): sd["material"] = "OTHER" - # allow 'remaining_g' as int - if "remaining_g" in sd and sd["remaining_g"] is not None: - try: - sd["remaining_g"] = float(sd["remaining_g"]) - except Exception: - sd["remaining_g"] = None + # Spoolman integration (optional) + sd.setdefault("spoolman_id", None) slots[slot_id] = sd # ensure all CFS banks exist (1A-4D) for sid in ( @@ -252,9 +440,17 @@ def _migrate_state_dict(data: dict) -> dict: "color_hex": "#00aaff", "name": "", "manufacturer": "", - "remaining_g": 0.0, - "notes": "", } + # Ensure the printer's direct spool input exists too. + if PRINTER_SPOOL_SLOT not in slots: + slots[PRINTER_SPOOL_SLOT] = { + "slot": PRINTER_SPOOL_SLOT, + "material": "OTHER", + "color_hex": "#00aaff", + "name": "", + "manufacturer": "", + "spoolman_id": None, + } data["slots"] = slots data.setdefault("printer_connected", False) @@ -264,49 +460,609 @@ def _migrate_state_dict(data: dict) -> dict: data.setdefault("cfs_last_update", 0.0) data.setdefault("cfs_active_slot", None) data.setdefault("cfs_slots", {}) - data.setdefault("cfs_raw", {}) - - # --- history defaults --- - data.setdefault("slot_history", {}) - data.setdefault("job_track_name", "") - data.setdefault("job_track_started_at", 0.0) - data.setdefault("job_track_last_mm", 0) - data.setdefault("job_track_slot_mm", {}) - data.setdefault("job_track_slot_g", {}) - data.setdefault("job_track_last_state", "") + data.setdefault("ws_slot_length_m", {}) + data.setdefault("cfs_stats", {}) + now_ts = _now() + env_hist_in = data.get("cfs_env_history") + env_hist_out: Dict[str, list] = {} + if isinstance(env_hist_in, dict): + for raw_box_id, raw_samples in env_hist_in.items(): + box_id = str(raw_box_id) + if box_id not in {"1", "2", "3", "4"}: + continue + if not isinstance(raw_samples, list): + continue + samples_out = _compact_cfs_env_history(raw_samples, now_ts) + if samples_out: + env_hist_out[box_id] = samples_out + data["cfs_env_history"] = env_hist_out + data.setdefault("job_history", []) - # Moonraker history snapshot - data.setdefault("moonraker_history", []) - data.setdefault("moonraker_allocations", {}) + # 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 -def load_state() -> AppState: +def _default_printer_id() -> str: + cfg = load_config() + printers = cfg.get("printers") or [] + if printers: + return str((printers[0] or {}).get("id") or "") + return "printer-1" + + +def _migrate_multi_state_dict(data: dict) -> dict: + """Normalize the multi-printer state envelope.""" + if not isinstance(data, dict): + return {"printers": {}, "updated_at": _now()} + + # Already multi-printer + if isinstance(data.get("printers"), dict): + printers_out: Dict[str, dict] = {} + for pid, raw in (data.get("printers") or {}).items(): + if not isinstance(raw, dict): + continue + printers_out[str(pid)] = _migrate_app_state_dict(raw) + updated_at = data.get("updated_at", _now()) + if isinstance(updated_at, str): + updated_at = _parse_iso_ts(updated_at) or _now() + return { + "printers": printers_out, + "updated_at": updated_at, + } + + # Legacy single-printer state + if isinstance(data.get("slots"), dict): + pid = _default_printer_id() + updated_at = data.get("updated_at", _now()) + if isinstance(updated_at, str): + updated_at = _parse_iso_ts(updated_at) or _now() + return { + "printers": {pid: _migrate_app_state_dict(data)}, + "updated_at": updated_at, + } + + return {"printers": {}, "updated_at": _now()} + + +_state_load_failed: bool = False # True when last load fell back to default +_jobdb_ready: bool = False +_jobdb_bootstrapped: bool = False +_jobdb_lock = Lock() + + +def _jobdb_connect() -> sqlite3.Connection: + conn = sqlite3.connect(str(JOB_DB_PATH), timeout=5.0) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def _jobdb_ensure() -> None: + global _jobdb_ready + if _jobdb_ready: + return + with _jobdb_lock: + if _jobdb_ready: + return + DATA_DIR.mkdir(parents=True, exist_ok=True) + with _jobdb_connect() as conn: + conn.execute("PRAGMA journal_mode=WAL") + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + printer_id TEXT NOT NULL, + job_name TEXT NOT NULL DEFAULT '', + reason TEXT NOT NULL DEFAULT '', + started_at REAL NOT NULL DEFAULT 0, + ended_at REAL NOT NULL DEFAULT 0, + total_grams REAL NOT NULL DEFAULT 0, + total_meters REAL NOT NULL DEFAULT 0, + source TEXT NOT NULL DEFAULT '', + moon_job_id TEXT NOT NULL DEFAULT '', + needs_link INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL DEFAULT (strftime('%s','now')) + ); + CREATE TABLE IF NOT EXISTS job_spools ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + slot TEXT NOT NULL DEFAULT '', + spoolman_id INTEGER, + material TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + manufacturer TEXT NOT NULL DEFAULT '', + color_hex TEXT NOT NULL DEFAULT '', + grams REAL NOT NULL DEFAULT 0, + meters REAL NOT NULL DEFAULT 0, + needs_link INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_jobs_printer_ended + ON jobs (printer_id, ended_at DESC, id DESC); + CREATE UNIQUE INDEX IF NOT EXISTS uq_jobs_printer_moon + ON jobs (printer_id, moon_job_id) + WHERE moon_job_id <> ''; + CREATE INDEX IF NOT EXISTS idx_job_spools_job + ON job_spools (job_id); + """ + ) + _jobdb_ready = True + + +def _job_history_insert_with_conn(conn: sqlite3.Connection, printer_id: str, job: dict, *, dedupe: bool = True) -> bool: + if not isinstance(job, dict): + return False + pid = str(printer_id or "").strip() + if not pid: + return False + + job_name = str(job.get("job_name") or "").strip() + reason = str(job.get("reason") or "").strip() + source = str(job.get("source") or "").strip() + moon_job_id = str(job.get("moon_job_id") or "").strip() + try: + started_at = float(job.get("started_at") or 0.0) + except Exception: + started_at = 0.0 + try: + ended_at = float(job.get("ended_at") or 0.0) + except Exception: + ended_at = 0.0 + try: + total_grams = max(0.0, float(job.get("total_grams") or 0.0)) + except Exception: + total_grams = 0.0 + try: + total_meters = max(0.0, float(job.get("total_meters") or 0.0)) + except Exception: + total_meters = 0.0 + needs_link = 1 if bool(job.get("needs_link")) else 0 + + if dedupe: + if moon_job_id: + row = conn.execute( + "SELECT id FROM jobs WHERE printer_id=? AND moon_job_id=? LIMIT 1", + (pid, moon_job_id), + ).fetchone() + if row: + return False + row = conn.execute( + "SELECT id FROM jobs WHERE printer_id=? AND ABS(ended_at - ?) <= 1.0 AND job_name=? LIMIT 1", + (pid, ended_at, job_name), + ).fetchone() + if row: + return False + + cur = conn.execute( + """ + INSERT INTO jobs ( + printer_id, job_name, reason, started_at, ended_at, + total_grams, total_meters, source, moon_job_id, needs_link + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + pid, job_name, reason, started_at, ended_at, + total_grams, total_meters, source, moon_job_id, needs_link, + ), + ) + job_id = int(cur.lastrowid or 0) + if not job_id: + return False + + spools = job.get("spools") if isinstance(job.get("spools"), list) else [] + for sp in spools: + if not isinstance(sp, dict): + continue + slot = str(sp.get("slot") or "").strip() + spoolman_id = _spoolman_id_or_none(sp.get("spoolman_id")) + material = str(sp.get("material") or "").strip().upper() + name = str(sp.get("name") or "").strip() + manufacturer = str(sp.get("manufacturer") or "").strip() + color_hex = _normalize_color_hex(str(sp.get("color_hex") or sp.get("color") or "")) + try: + grams = max(0.0, float(sp.get("grams") or 0.0)) + except Exception: + grams = 0.0 + try: + meters = max(0.0, float(sp.get("meters") or 0.0)) + except Exception: + meters = 0.0 + sp_needs_link = 1 if bool(sp.get("needs_link")) else 0 + + conn.execute( + """ + INSERT INTO job_spools ( + job_id, slot, spoolman_id, material, name, manufacturer, + color_hex, grams, meters, needs_link + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + job_id, + slot, + spoolman_id, + material, + name, + manufacturer, + color_hex, + grams, + meters, + sp_needs_link, + ), + ) + return True + + +def _jobdb_bootstrap_if_needed() -> None: + global _jobdb_bootstrapped + _jobdb_ensure() + if _jobdb_bootstrapped: + return + with _jobdb_lock: + if _jobdb_bootstrapped: + return + imported = 0 + with _jobdb_connect() as conn: + existing = int(conn.execute("SELECT COUNT(*) AS c FROM jobs").fetchone()["c"] or 0) + if existing == 0: + st = load_state_all() + for pid, app_st in (st.printers or {}).items(): + hist = app_st.job_history if isinstance(app_st.job_history, list) else [] + for job in hist: + if _job_history_insert_with_conn(conn, pid, job, dedupe=True): + imported += 1 + conn.commit() + if imported: + print(f"[JOBDB] Imported {imported} legacy jobs from state.json") + _jobdb_bootstrapped = True + + +def _job_history_insert(printer_id: str, job: dict, *, dedupe: bool = True) -> bool: + _jobdb_bootstrap_if_needed() + with _jobdb_connect() as conn: + inserted = _job_history_insert_with_conn(conn, printer_id, job, dedupe=dedupe) + if inserted: + conn.commit() + return inserted + + +def _job_history_fetch(printer_id: str, *, limit: Optional[int] = None) -> list: + _jobdb_bootstrap_if_needed() + pid = str(printer_id or "").strip() + if not pid: + return [] + + with _jobdb_connect() as conn: + params: list = [pid] + sql = """ + SELECT id, printer_id, job_name, reason, started_at, ended_at, + total_grams, total_meters, source, moon_job_id, needs_link + FROM jobs + WHERE printer_id=? + ORDER BY ended_at ASC, id ASC + """ + if limit is not None and limit > 0: + sql = """ + SELECT id, printer_id, job_name, reason, started_at, ended_at, + total_grams, total_meters, source, moon_job_id, needs_link + FROM jobs + WHERE printer_id=? + ORDER BY ended_at DESC, id DESC + LIMIT ? + """ + params.append(int(limit)) + job_rows = conn.execute(sql, tuple(params)).fetchall() + if limit is not None and limit > 0: + job_rows = list(reversed(job_rows)) + + job_ids = [int(r["id"]) for r in job_rows] + spool_map: Dict[int, list] = {jid: [] for jid in job_ids} + if job_ids: + placeholders = ",".join(["?"] * len(job_ids)) + spool_rows = conn.execute( + f""" + SELECT id, job_id, slot, spoolman_id, material, name, manufacturer, + color_hex, grams, meters, needs_link + FROM job_spools + WHERE job_id IN ({placeholders}) + ORDER BY id ASC + """, + tuple(job_ids), + ).fetchall() + for sp in spool_rows: + spool_map.setdefault(int(sp["job_id"]), []).append({ + "slot": str(sp["slot"] or ""), + "spoolman_id": sp["spoolman_id"], + "material": str(sp["material"] or ""), + "name": str(sp["name"] or ""), + "manufacturer": str(sp["manufacturer"] or ""), + "color_hex": str(sp["color_hex"] or ""), + "grams": float(sp["grams"] or 0.0), + "meters": float(sp["meters"] or 0.0), + "needs_link": bool(sp["needs_link"] or 0), + }) + + out: list = [] + for row in job_rows: + jid = int(row["id"]) + out.append({ + "printer_id": str(row["printer_id"] or pid), + "job_name": str(row["job_name"] or ""), + "reason": str(row["reason"] or ""), + "started_at": float(row["started_at"] or 0.0), + "ended_at": float(row["ended_at"] or 0.0), + "spools": spool_map.get(jid, []), + "total_grams": float(row["total_grams"] or 0.0), + "total_meters": float(row["total_meters"] or 0.0), + "source": str(row["source"] or ""), + "moon_job_id": str(row["moon_job_id"] or ""), + "needs_link": bool(row["needs_link"] or 0), + }) + return out + + +def _job_history_query( + *, + printer_id: Optional[str] = None, + material: str = "", + spoolman_id: Optional[int] = None, + needs_link: Optional[bool] = None, + q: str = "", + from_ts: Optional[float] = None, + to_ts: Optional[float] = None, + limit: int = 50, + offset: int = 0, +) -> tuple[list, int]: + _jobdb_bootstrap_if_needed() + where: list[str] = ["1=1"] + params: list = [] + + pid = str(printer_id or "").strip() + if pid: + where.append("j.printer_id = ?") + params.append(pid) + + mat = str(material or "").strip().upper() + if mat: + where.append( + "EXISTS (SELECT 1 FROM job_spools sm WHERE sm.job_id = j.id AND UPPER(sm.material) = ?)" + ) + params.append(mat) + + sid = _spoolman_id_or_none(spoolman_id) + if sid: + where.append( + "EXISTS (SELECT 1 FROM job_spools ss WHERE ss.job_id = j.id AND ss.spoolman_id = ?)" + ) + params.append(sid) + + if needs_link is not None: + where.append("j.needs_link = ?") + params.append(1 if needs_link else 0) + + qn = str(q or "").strip().lower() + if qn: + like = f"%{qn}%" + where.append( + "(LOWER(j.job_name) LIKE ? OR LOWER(j.reason) LIKE ? OR LOWER(j.moon_job_id) LIKE ?)" + ) + params.extend([like, like, like]) + + if from_ts is not None: + where.append("j.ended_at >= ?") + params.append(float(from_ts)) + if to_ts is not None: + where.append("j.ended_at <= ?") + params.append(float(to_ts)) + + where_sql = " AND ".join(where) + limit = max(1, min(200, int(limit))) + offset = max(0, int(offset)) + + with _jobdb_connect() as conn: + total = int( + conn.execute( + f"SELECT COUNT(*) AS c FROM jobs j WHERE {where_sql}", + tuple(params), + ).fetchone()["c"] or 0 + ) + job_rows = conn.execute( + f""" + SELECT id, printer_id, job_name, reason, started_at, ended_at, + total_grams, total_meters, source, moon_job_id, needs_link + FROM jobs j + WHERE {where_sql} + ORDER BY ended_at DESC, id DESC + LIMIT ? OFFSET ? + """, + tuple(params + [limit, offset]), + ).fetchall() + + job_ids = [int(r["id"]) for r in job_rows] + spool_map: Dict[int, list] = {jid: [] for jid in job_ids} + if job_ids: + placeholders = ",".join(["?"] * len(job_ids)) + spool_rows = conn.execute( + f""" + SELECT id, job_id, slot, spoolman_id, material, name, manufacturer, + color_hex, grams, meters, needs_link + FROM job_spools + WHERE job_id IN ({placeholders}) + ORDER BY id ASC + """, + tuple(job_ids), + ).fetchall() + for sp in spool_rows: + spool_map.setdefault(int(sp["job_id"]), []).append({ + "slot": str(sp["slot"] or ""), + "spoolman_id": sp["spoolman_id"], + "material": str(sp["material"] or ""), + "name": str(sp["name"] or ""), + "manufacturer": str(sp["manufacturer"] or ""), + "color_hex": str(sp["color_hex"] or ""), + "grams": float(sp["grams"] or 0.0), + "meters": float(sp["meters"] or 0.0), + "needs_link": bool(sp["needs_link"] or 0), + }) + + out: list = [] + for row in job_rows: + jid = int(row["id"]) + out.append({ + "printer_id": str(row["printer_id"] or ""), + "job_name": str(row["job_name"] or ""), + "reason": str(row["reason"] or ""), + "started_at": float(row["started_at"] or 0.0), + "ended_at": float(row["ended_at"] or 0.0), + "spools": spool_map.get(jid, []), + "total_grams": float(row["total_grams"] or 0.0), + "total_meters": float(row["total_meters"] or 0.0), + "source": str(row["source"] or ""), + "moon_job_id": str(row["moon_job_id"] or ""), + "needs_link": bool(row["needs_link"] or 0), + }) + return out, total + + +def _job_history_filter_options() -> dict: + _jobdb_bootstrap_if_needed() + with _jobdb_connect() as conn: + db_printers = [ + str(r["printer_id"] or "").strip() + for r in conn.execute( + "SELECT DISTINCT printer_id FROM jobs WHERE printer_id <> '' ORDER BY printer_id ASC" + ).fetchall() + ] + db_materials = [ + str(r["material"] or "").strip().upper() + for r in conn.execute( + "SELECT DISTINCT material FROM job_spools WHERE material <> '' ORDER BY material ASC" + ).fetchall() + ] + + cfg_printers = [str((p or {}).get("id") or "").strip() for p in (load_config().get("printers") or [])] + printers: list[str] = [] + for pid in cfg_printers + db_printers: + if pid and pid not in printers: + printers.append(pid) + + materials: list[str] = [] + for mat in db_materials: + if mat and mat not in materials: + materials.append(mat) + + return { + "printers": printers, + "materials": materials, + } + + +def load_state_all() -> MultiAppState: + 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) + data = _migrate_multi_state_dict(data) + result = _model_validate(MultiAppState, data) + # Ensure configured printers exist in state + cfg_printers = load_config().get("printers") or [] + for p in cfg_printers: + pid = str((p or {}).get("id") or "") + if not pid: + continue + # If state is keyed by address but config now uses a custom id, migrate it. + addr = str((p or {}).get("address") or "") + if pid not in result.printers and addr in result.printers and addr != pid: + result.printers[pid] = result.printers.pop(addr) + if pid not in result.printers: + result.printers[pid] = default_state() + _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}") - return default_state() + _state_load_failed = True + # Return a safe in-memory fallback. save_state_all() will attempt to + # repair state.json on the next write instead of freezing persistence. + return default_multi_state() -def _job_key(job_id: str, ts_end: Optional[float], job: str) -> str: - """Build a stable key for a job in our local allocation store.""" - j = (job_id or "").strip() or (job or "").strip() +def save_state_all(state: MultiAppState) -> None: + global _state_load_failed + if _state_load_failed: + print("[STATE] previous load failed; attempting recovery save") try: - te = float(ts_end) if ts_end is not None else 0.0 - except Exception: - te = 0.0 - return f"{j}:{te:.0f}" + state.updated_at = _now() + _write_json_atomic(STATE_PATH, _model_dump(state)) + _state_load_failed = False + except Exception as e: + _state_load_failed = True + print(f"[STATE] save failed: {e}") + + +def _all_printer_ids() -> List[str]: + cfg_printers = load_config().get("printers") or [] + cfg_ids = [str((p or {}).get("id") or "") for p in cfg_printers if (p or {}).get("id")] + st = load_state_all() + state_ids = [str(x) for x in st.printers.keys()] + merged: List[str] = [] + for pid in cfg_ids + state_ids: + if pid and pid not in merged: + merged.append(pid) + return merged + + +def _resolve_printer_id(printer_id: Optional[str], *, allow_unknown: bool = False) -> str: + raw = (printer_id or "").strip() + cfg_ids = {str((p or {}).get("id") or "").strip() for p in (load_config().get("printers") or [])} + if raw and raw in cfg_ids: + pid = raw + else: + pid = _normalize_printer_host(raw) + if not pid: + pid = _default_printer_id() + else: + # If caller passed an address, map it to configured id if present + for p in (load_config().get("printers") or []): + addr = str((p or {}).get("address") or "").strip() + cid = str((p or {}).get("id") or "").strip() + if addr and cid and pid == addr: + pid = cid + break + if not allow_unknown: + known = set(_all_printer_ids()) + if pid not in known: + raise HTTPException(status_code=404, detail="Unknown printer") + return pid + + +def _printer_address(printer_id: str) -> str: + pid = (printer_id or "").strip() + if not pid: + return "" + for p in (load_config().get("printers") or []): + cid = str((p or {}).get("id") or "").strip() + addr = str((p or {}).get("address") or "").strip() + if cid and addr and cid == pid: + return addr + # Fallback: treat printer_id as host/IP + return _normalize_printer_host(pid) + + +def load_state(printer_id: Optional[str] = None) -> AppState: + pid = _resolve_printer_id(printer_id, allow_unknown=True) + st = load_state_all() + state = st.printers.get(pid) + if state is None: + return default_state() + return state -def save_state(state: AppState) -> None: - state.updated_at = _now() - STATE_PATH.write_text(json.dumps(_model_dump(state), indent=2, ensure_ascii=False)) +def save_state(printer_id: str, state: AppState) -> None: + pid = _resolve_printer_id(printer_id, allow_unknown=True) + st = load_state_all() + st.printers[pid] = state + save_state_all(st) # --- Printer adapter (Dummy) --- @@ -339,671 +1095,1241 @@ def mm_to_g(material: str, mm: float) -> float: return float(max(0.0, g)) -def _apply_job_usage(state: AppState, job_name: str, total_used_mm: int, slot_override: Optional[str] = None) -> None: - """Update job counters. - Note: We intentionally do NOT decrement remaining_g here anymore. - Creality K2's CFS can change slots mid-print (multi-color). Accurate - remaining deduction is handled by the per-slot tracker finalized at - print end. - """ - total_used_mm = int(max(0, total_used_mm)) +# --- Minimal Moonraker polling (optional) --- - # Decide which slot to account against - slot_id = slot_override or state.last_accounted_slot or state.active_slot +def _http_get_json(url: str, timeout: float = 2.5) -> dict: + # NOTE: FastAPI also exports a Request type; avoid name clash by using + # UrlRequest for outbound HTTP requests. + req = UrlRequest(url, headers={"User-Agent": "filament-manager/1.0"}) + with urlopen(req, timeout=timeout) as r: + raw = r.read().decode("utf-8", errors="replace") + return json.loads(raw) - # If job name changed, reset delta baseline - if job_name != (state.current_job or ""): - state.last_accounted_job_mm = 0 - delta_mm = max(0, total_used_mm - int(state.last_accounted_job_mm or 0)) +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 {} - material = state.slots[slot_id].material - # Update state - state.current_job = job_name - state.current_job_filament_mm = total_used_mm - state.current_job_filament_g = mm_to_g(material, float(total_used_mm)) - state.last_accounted_job_mm = total_used_mm - state.last_accounted_slot = slot_id +# --- 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 _hist_push(state: AppState, slot_id: str, entry: dict, keep: int = 50) -> None: - """Append a history entry for a slot (newest first).""" - try: - # Tag entries with the current spool epoch so UI can hide old-roll prints - try: - entry.setdefault("epoch", int(getattr(state.slots.get(slot_id), "spool_epoch", 0) or 0)) - except Exception: - entry.setdefault("epoch", 0) - h = state.slot_history.get(slot_id) - if not isinstance(h, list): - h = [] - h.insert(0, entry) - state.slot_history[slot_id] = h[:keep] - except Exception: - # never fail the poll loop due to history - pass +def _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 _hist_upsert_by_src(state: AppState, slot_id: str, src: str, entry: dict, keep: int = 50) -> None: - """Insert or replace a history entry identified by a stable _src marker. - Used to show a "live" (in-progress) entry per slot during printing without - spamming the history list. - """ +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: - if not src: - _hist_push(state, slot_id, entry, keep=keep) - return + 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}") - entry["_src"] = src - # Tag entries with the current spool epoch so UI can hide old-roll prints - try: - entry.setdefault("epoch", int(getattr(state.slots.get(slot_id), "spool_epoch", 0) or 0)) - except Exception: - entry.setdefault("epoch", 0) +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}") - h = state.slot_history.get(slot_id) - if not isinstance(h, list): - h = [] - # Drop existing entries with same source marker - h = [e for e in h if not (isinstance(e, dict) and e.get("_src") == src)] - h.insert(0, entry) - state.slot_history[slot_id] = h[:keep] +def _spoolman_remaining_weight(spool: dict) -> float: + try: + return max(0.0, float(spool.get("remaining_weight") or 0.0)) except Exception: - # never fail the poll loop due to history - pass + return 0.0 + +def _spoolman_set_remaining_weight(base: str, spool_id: int, weight_g: float) -> None: + """PATCH /api/v1/spool/{id} with an exact remaining_weight. Raises on failure.""" + if not spool_id: + raise ValueError("Invalid spool ID") + url = f"{base}/api/v1/spool/{spool_id}" + data = json.dumps({"remaining_weight": round(max(0.0, 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=5.0) as r: + r.read() -def _inc_slot_epoch_consumed(state: AppState, slot_id: str, delta_g: float) -> None: - """Increment the running consumed-total for the current spool epoch.""" + +def _spoolman_id_or_none(value) -> Optional[int]: try: - s = state.slots.get(slot_id) - if not s: - return - s.spool_epoch_consumed_g_total = float(getattr(s, "spool_epoch_consumed_g_total", 0.0) or 0.0) + float(delta_g) - state.slots[slot_id] = s + sid = int(value) + return sid if sid > 0 else None except Exception: - return + return None -# --- Minimal Moonraker polling (optional) --- +def _normalize_color_hex(value: str) -> str: + raw = str(value or "").strip().lower() + if not raw: + return "" + if raw.startswith("0x"): + raw = raw[2:] + if raw.startswith("#"): + raw = raw[1:] + raw = "".join(ch for ch in raw if ch in "0123456789abcdef") + if not raw: + return "" + # Handle common printer formats: + # - 0RRGGBB -> strip leading 0 + # - AARRGGBB -> strip alpha + # - anything longer -> keep least significant RGB bytes + if len(raw) == 7 and raw[0] == "0": + raw = raw[1:] + elif len(raw) == 8: + raw = raw[2:] + elif len(raw) > 8: + raw = raw[-6:] + if len(raw) != 6: + return "" + return "#" + raw + + +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}" + # 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", + }, 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 _http_get_json(url: str, timeout: float = 2.5) -> dict: - # NOTE: FastAPI also exports a Request type; avoid name clash by using - # UrlRequest for outbound HTTP requests. - req = UrlRequest(url, headers={"User-Agent": "filament-manager/1.0"}) - with urlopen(req, timeout=timeout) as r: - raw = r.read().decode("utf-8", errors="replace") - return json.loads(raw) +def _spoolman_job_color_lookup(spool_id: int) -> str: + """Return current filament color for a spool (cached for UI history rendering).""" + sid = _spoolman_id_or_none(spool_id) + if not sid: + return "" + now = _now() + cached = _spoolman_job_color_cache.get(sid) + if cached and (now - cached[0]) <= _SPOOLMAN_JOB_COLOR_CACHE_TTL: + return cached[1] -def _moonraker_fetch_history(base: str, limit: int = 20) -> list[dict]: - """Fetch Moonraker job history list (best effort). + base = _spoolman_base_url() + if not base: + return "" + try: + spool = _spoolman_get_spool(base, sid) + filament = spool.get("filament") or {} + color = _normalize_color_hex(str(filament.get("color_hex") or "")) + _spoolman_job_color_cache[sid] = (now, color) + return color + except Exception: + return "" - Moonraker provides this at: - GET /server/history/list?limit=&order=desc - Note: Creality firmware usually exposes the history component, but - per-slot attribution is not guaranteed. - """ + +def _ui_hydrate_job_history_colors(history_in: list) -> list: + """Hydrate history spool colors from current linked Spoolman spool metadata.""" + if not isinstance(history_in, list): + return [] + out: list = [] + for job in history_in: + if not isinstance(job, dict): + continue + job_out = dict(job) + spools_in = job.get("spools") or [] + spools_out: list = [] + if isinstance(spools_in, list): + for sp in spools_in: + if not isinstance(sp, dict): + continue + sp_out = dict(sp) + sid = _spoolman_id_or_none(sp_out.get("spoolman_id")) + color = _spoolman_job_color_lookup(sid or 0) if sid else "" + if color: + sp_out["color_hex"] = color + else: + sp_out["color_hex"] = _normalize_color_hex(str(sp_out.get("color_hex") or sp_out.get("color") or "")) + spools_out.append(sp_out) + job_out["spools"] = spools_out + out.append(job_out) + return out + + +def _spoolman_autolink_by_rfid(slot: str, rfid: str, st, printer_id: str) -> None: + """Search active Spoolman spools for one with extra.cfs_rfid == rfid and auto-link.""" + base = _spoolman_base_url() + if not base or not rfid: + return try: - url = base.rstrip("/") + "/server/history/list?" + urlencode({"limit": int(limit), "order": "desc"}) - data = _http_get_json(url, timeout=3.5) - jobs = (((data or {}).get("result") or {}).get("jobs") or []) - out: list[dict] = [] - for j in jobs: - if not isinstance(j, dict): - continue - fn = j.get("filename") or "" - if isinstance(fn, str) and "/" in fn: - fn = fn.rsplit("/", 1)[-1] - # Moonraker reports filament_used as float; documentation says mm, - # however some frontends treat it as meters. We keep both a raw - # value and a derived mm estimate. - fu = j.get("filament_used") - fu_raw = None - fu_mm = None + 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 {} + raw = extra.get("cfs_rfid", "") + # Spoolman stores extra values as JSON-encoded strings — decode before comparing try: - fu_raw = float(fu) - # Heuristic: if the value is small (< 200) it's likely meters. - # Otherwise treat it as mm. - fu_mm = fu_raw * 1000.0 if fu_raw < 200 else fu_raw + stored_rfid = json.loads(raw) if raw else "" except Exception: - pass + stored_rfid = raw + if stored_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.setdefault(printer_id, {})[slot] = rfid + save_state(printer_id, st) + print(f"[SPOOLMAN] ({printer_id}) 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}") - meta = j.get("metadata") or {} - fu_g_list = None - try: - lst = meta.get("filament_used_g") - if isinstance(lst, list) and lst: - fu_g_list = [float(x) for x in lst] - except Exception: - fu_g_list = None - # If firmware didn't provide grams, compute a best-effort estimate from mm + filament_type - fu_g_total = None - try: - if isinstance(fu_g_list, list) and fu_g_list: - fu_g_total = float(sum(fu_g_list)) - elif fu_mm is not None: - mat = None - if isinstance(meta, dict): - mat = meta.get("filament_type") - mat_s = str(mat).strip().upper() if mat else "OTHER" - fu_g_total = float(mm_to_g(mat_s, float(fu_mm))) - except Exception: - fu_g_total = None +async def _fetch_printer_material_json(printer_id: str) -> Optional[dict]: + """Fetch material_box_info.json from the printer via SSH (system ssh binary).""" + host = (_printer_address(printer_id) or "").strip().split(":")[0] + if not host: + return None - out.append( - { - "job_id": j.get("job_id") or j.get("uid") or "", - "ts_start": j.get("start_time"), - "ts_end": j.get("end_time"), - "status": j.get("status") or "", - "job": fn, - "filament_used_raw": fu_raw, - "filament_used_mm": fu_mm, - "filament_used_g": fu_g_list, - "filament_used_g_total": (float(round(fu_g_total, 2)) if fu_g_total is not None else None), - "filament_type": (meta.get("filament_type") if isinstance(meta, dict) else None), - "colors": (meta.get("default_filament_colour") if isinstance(meta, dict) else None), - } + def _ssh_cat() -> Optional[dict]: + import subprocess + try: + result = subprocess.run( + [ + "sshpass", "-p", "creality_2023", + "ssh", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=5", + f"root@{host}", + "cat /usr/data/creality/userdata/box/material_box_info.json", + ], + capture_output=True, text=True, timeout=10, ) - return out + if result.returncode == 0 and result.stdout.strip(): + return json.loads(result.stdout) + print(f"[SSH] fetch failed ({host}): {result.stderr.strip() or 'no output'}") + return None + except FileNotFoundError: + print("[SSH] sshpass not found; run: apt install sshpass") + return None + except Exception as e: + print(f"[SSH] fetch failed ({host}): {e}") + return None + + return await asyncio.get_event_loop().run_in_executor(None, _ssh_cat) + + +def _apply_serialnum_links(info: dict, printer_id: str) -> None: + """Parse material_box_info.json; link slots whose serialNum is a valid Spoolman spool ID.""" + base = _spoolman_base_url() + st = load_state(printer_id) + changed = False + + for box in (info.get("Material", {}).get("info") or []): + box_id_str = box.get("boxID", "") # "T1" .. "T4" + if not box_id_str.startswith("T"): + continue + box_num = box_id_str[1:] + + for mat in (box.get("list") or []): + mat_id = mat.get("materialId", "") # "A" .. "D" + slot = f"{box_num}{mat_id}" + if slot not in _VALID_CFS_SLOT_IDS: + continue + + serial = (mat.get("serialNum") or "").strip() + if not serial or serial == "000000": + continue + try: + spool_id = int(serial) + except ValueError: + continue + if spool_id <= 0: + continue + + slot_obj = st.slots.get(slot) + if slot_obj and getattr(slot_obj, "spoolman_id", None) == spool_id: + continue # already linked + + if base: + try: + spool = _http_get_json(f"{base}/api/v1/spool/{spool_id}", timeout=5.0) + if not isinstance(spool, dict) or not spool.get("id"): + print(f"[SSH] Slot {slot}: serialNum {serial!r} → spool {spool_id} not in Spoolman") + continue + except Exception as e: + print(f"[SSH] Slot {slot}: Spoolman lookup failed for spool {spool_id}: {e}") + continue + + if slot_obj is None: + slot_obj = SlotState(slot=slot) + slot_obj.spoolman_id = spool_id + st.slots[slot] = slot_obj + changed = True + print(f"[SSH] Slot {slot}: linked → Spoolman spool {spool_id} via serialNum {serial!r}") + + if changed: + save_state(printer_id, st) + + +async def _ssh_fetch_and_apply(printer_id: str) -> None: + """Fetch material_box_info.json via SSH and apply serialNum-based auto-links.""" + _ssh_last_fetch[printer_id] = time.time() + info = await _fetch_printer_material_json(printer_id) + if info: + _apply_serialnum_links(info, printer_id) + + +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 [] + return 999.0 + + +_WS_SAVE_INTERVAL = 10.0 +_ws_last_save: Dict[str, float] = {} +_ws_last_rfid: Dict[str, Dict[str, str]] = {} # printer_id → slot → RFID +_ws_last_state: Dict[str, Dict[str, int]] = {} # printer_id → slot → CFS state (0/1/2) +_ws_last_fingerprint: Dict[str, Dict[str, str]] = {} # printer_id → slot → material fingerprint + +_SSH_FETCH_COOLDOWN = 30.0 # seconds between SSH fetches of material_box_info.json +_ssh_last_fetch: Dict[str, float] = {} + +_moon_last_state: Dict[str, str] = {} # printer_id → last known print_stats.state +_moon_last_filament_mm: Dict[str, float] = {} # printer_id → filament_used at last poll tick +_moon_job_track_slot_g: Dict[str, Dict[str, float]] = {} # printer_id → slot → grams +_moon_job_track_slot_mm: Dict[str, Dict[str, float]] = {} # printer_id → slot → mm +_moon_job_started_at: Dict[str, float] = {} # printer_id → Unix timestamp +_moon_job_name: Dict[str, str] = {} # printer_id → filename/job name +_moon_history_last_sync: Dict[str, float] = {} # printer_id → last Moonraker history sync ts +_MOON_HISTORY_SYNC_INTERVAL = 60.0 +_MOON_HISTORY_PAGE_LIMIT = 50 +_MOON_HISTORY_OVERLAP_SECS = 60.0 +_MOON_HISTORY_TERMINAL_STATES = frozenset({ + "completed", + "cancelled", + "error", + "klippy_shutdown", + "klippy_disconnect", + "interrupted", +}) + +_VALID_CFS_SLOT_IDS = frozenset( + f"{b}{l}" for b in "1234" for l in "ABCD" +) -def _moonraker_build_url(base: str, objects: list[str]) -> str: - """Build Moonraker objects/query URL. +# Log unknown WS message top-level keys once per session to aid discovery +_ws_seen_keys: Dict[str, set] = {} - Moonraker supports multiple syntaxes depending on version/vendor fork. - Creality K-series (K2 Plus) reliably supports the ampersand form: - /printer/objects/query?print_stats&virtual_sdcard&box&filament_rack +# Spoolman-derived percent cache for manual (non-RFID) slots +_spoolman_manual_pct: Dict[str, Dict[str, Optional[int]]] = {} # printer_id → slot → percent or None +_spoolman_pct_refresh_at: Dict[str, Dict[str, float]] = {} # printer_id → slot → next refresh timestamp +_SPOOLMAN_PCT_TTL = 60.0 +_spoolman_job_color_cache: Dict[int, tuple[float, str]] = {} # spool_id → (ts, "#rrggbb"|"") +_SPOOLMAN_JOB_COLOR_CACHE_TTL = 30.0 - Some upstream versions also accept `objects=toolhead,print_stats`, but that - isn't consistently supported on Creality firmware. For maximum compatibility - we use the ampersand form. - """ - safe = [str(o).strip() for o in (objects or []) if str(o).strip()] - qs = "&".join(safe) - return base.rstrip("/") + "/printer/objects/query?" + qs +# Known WS key names for printer identity (tried in order) +_WS_NAME_KEYS = ("hostname", "machineName", "printerName", "deviceName", "model", "MachineModel", "deviceModel") +_WS_FW_KEYS = ("softVersion", "firmwareVersion", "version", "FirmwareVersion", "SoftwareVersion", "firmware") -def _moonraker_list_objects(base: str) -> list[str]: - data = _http_get_json(base.rstrip("/") + "/printer/objects/list") - return list((((data or {}).get("result") or {}).get("objects") or [])) +def _printer_ws_url(printer_id: str) -> str: + host = _printer_address(printer_id) + if not host: + return "" + return f"ws://{host.split(':')[0]}:9999" -def _walk(obj, path=""): - # generator over (path, value) for nested dict/list - if isinstance(obj, dict): - for k, v in obj.items(): - p = f"{path}.{k}" if path else str(k) - yield p, v - yield from _walk(v, p) - elif isinstance(obj, list): - for i, v in enumerate(obj): - p = f"{path}[{i}]" - yield p, v - yield from _walk(v, p) +def _moonraker_base_url(printer_id: str) -> 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 + cfg_ids = [str((p or {}).get("id") or "") for p in (cfg.get("printers") or [])] + if len(cfg_ids) <= 1 or host == _printer_address(printer_id): + return f"http://{host}:{port}" + host = (_printer_address(printer_id) or "").split(":")[0] + return f"http://{host}:7125" if host else "" + + +def _normalize_ws_color(raw: str) -> str: + """Normalize printer color payloads to '#rrggbb'.""" + return _normalize_color_hex(raw) + + +def _cfs_env_value_changed(prev: Optional[float], cur: Optional[float], min_delta: float) -> bool: + if cur is None: + return prev is not None + if prev is None: + return True + return abs(cur - prev) >= min_delta + + +def _record_cfs_env_sample( + st: AppState, + box_id: int, + *, + ts: float, + temperature_c: Optional[float], + humidity_pct: Optional[float], +) -> None: + box_key = str(box_id) + temp = _as_finite_float_or_none(temperature_c) + hum = _as_finite_float_or_none(humidity_pct) + if temp is None and hum is None: + return + raw_hist = st.cfs_env_history.get(box_key) or [] + hist: list[dict] = [] + if isinstance(raw_hist, list): + for item in raw_hist: + coerced = _coerce_cfs_env_sample(item) + if coerced: + hist.append(coerced) + + should_append = True + if hist: + last = hist[-1] + last_ts = _as_finite_float_or_none(last.get("ts")) or 0.0 + dt = ts - last_ts + prev_t = _as_finite_float_or_none(last.get("temperature_c")) + prev_h = _as_finite_float_or_none(last.get("humidity_pct")) + temp_changed = _cfs_env_value_changed(prev_t, temp, _CFS_ENV_TEMP_DELTA) + hum_changed = _cfs_env_value_changed(prev_h, hum, _CFS_ENV_HUMIDITY_DELTA) + should_append = (dt >= _CFS_ENV_MIN_SAMPLE_INTERVAL) or temp_changed or hum_changed + + if not should_append: + return -_SLOT_RE = __import__("re").compile(r"^[1-4][A-D]$") + sample = {"ts": float(ts)} + if temp is not None: + sample["temperature_c"] = round(temp, 2) + if hum is not None: + sample["humidity_pct"] = round(hum, 2) + hist.append(sample) + st.cfs_env_history[box_key] = _compact_cfs_env_history(hist, ts) -def _extract_cfs_slot_data(status: dict) -> tuple[Optional[str], dict]: - """Best-effort extraction of CFS slot metadata from Moonraker status. +def _parse_ws_printer_info(payload: dict, printer_id: str) -> None: + """Extract printer name / firmware from any WS status message and persist to state. - Creality's firmware is not standardized, so we try heuristics: - - Any dict key that looks like '1A', '2D', ... is treated as a slot. - - Any nested dict with fields like slot/id/index and color/material/name. - Returns (active_slot, slots_dict). + Also logs any previously-unseen top-level keys once per session so we can + discover the exact field names the printer uses. """ - active = None - slots: dict[str, dict] = {} - - # --- Creality K-series "box" + "filament_rack" objects (K2 Plus / CFS) --- - # Firmware exposes: - # box.T1..T4 with arrays: color_value/material_type/remain_len, and box..filament = "A".."D" - # filament_rack.remain_material_color/type - # We normalize to internal slot ids: "1A".."4D". + seen = _ws_seen_keys.setdefault(printer_id, set()) + new_keys = set(payload.keys()) - seen + if new_keys: + seen |= new_keys + _ws_seen_keys[printer_id] = seen + print(f"[WS] ({printer_id}) New message keys: {sorted(new_keys)}") + + name = "" + for k in _WS_NAME_KEYS: + v = str(payload.get(k) or "").strip() + if v: + name = v + break + + fw = "" + for k in _WS_FW_KEYS: + v = str(payload.get(k) or "").strip() + if v: + fw = v + break + # Parse "modelVersion" field: "printer hw ver:;printer sw ver:;DWIN sw ver:1.1.3.13;" + if not fw: + mv = str(payload.get("modelVersion") or "").strip() + if mv: + for part in mv.split(";"): + part = part.strip() + if "sw ver:" in part.lower() and ":" in part: + ver = part.split(":", 1)[1].strip() + if ver: + fw = ver + break + + if not name and not fw: + return + + st = load_state(printer_id) + changed = False + if name and name != st.printer_name: + st.printer_name = name + changed = True + print(f"[WS] ({printer_id}) Printer name: {name!r}") + if fw and fw != st.printer_firmware: + st.printer_firmware = fw + changed = True + print(f"[WS] ({printer_id}) Firmware: {fw!r}") + if changed: + save_state(printer_id, st) + + +def _parse_ws_cfs_data(payload: dict, printer_id: str) -> None: + """Parse a boxsInfo WS payload and update local state + Spoolman.""" try: - box = (status or {}).get("box") - rack = (status or {}).get("filament_rack") - if isinstance(box, dict): - # Build lookups from box.same_material: [material_code, color_code, ["T2D"], "ABS"] - mat_name_by_code: dict[str, str] = {} - sm = box.get("same_material") - if isinstance(sm, list): - for row in sm: - if not isinstance(row, list) or len(row) < 4: - continue - mcode, _ccode, _slots_list, mname = row[0], row[1], row[2], row[3] - if isinstance(mcode, str) and isinstance(mname, str): - mat_name_by_code[mcode] = mname.strip().upper() - - def _hex_color(creality_val: str) -> Optional[str]: - if not isinstance(creality_val, str): - return None - v = creality_val.strip().lower() - # values look like "0ffa800" or "00a2989"; take last 6 hex chars - hex6 = v[-6:] - if len(hex6) == 6 and all(ch in "0123456789abcdef" for ch in hex6): - return f"#{hex6}".lower() - return None - - boxes: dict[str, dict] = {} - - for ti in ("T1", "T2", "T3", "T4"): - t = box.get(ti) - if not isinstance(t, dict): - continue + boxes = (payload.get("boxsInfo") or {}).get("materialBoxs") or [] + except Exception: + return - # Box connection state: "connect" when a CFS is present. - bnum = str(ti[1]) - bstate = str(t.get("state") or "") - is_conn = (bstate.lower() == "connect") - boxes[bnum] = { - "connected": is_conn, - "state": bstate, - # Best-effort environmental info per CFS box (Creality) - "temperature_c": None, - "humidity_pct": None, - } + st = load_state(printer_id) + last_rfid = _ws_last_rfid.setdefault(printer_id, {}) + last_state = _ws_last_state.setdefault(printer_id, {}) + last_fingerprint = _ws_last_fingerprint.setdefault(printer_id, {}) + manual_pct = _spoolman_manual_pct.setdefault(printer_id, {}) + active_slot: Optional[str] = None + boxes_meta: dict = {} + seen_slots: set[str] = set() + now_ts = _now() + + def _process_material_slot(slot: str, mat: dict, *, allow_ssh_serial_lookup: bool) -> None: + nonlocal active_slot + raw_state_val = int(mat.get("state") or 0) + mat_type_raw = str(mat.get("type") or "").strip().upper() + name_raw = str(mat.get("name") or "").strip() + vendor_raw = str(mat.get("vendor") or "").strip() + rfid_raw = str(mat.get("rfid") or "").strip() + raw_color = mat.get("color", "") + color_norm = _normalize_ws_color(raw_color) + slot_fingerprint = "|".join([mat_type_raw, name_raw, vendor_raw, (color_norm or "").lower()]) + rfid_missing = rfid_raw in ("", "0", "00", "000", "0000", "00000", "000000") + # Creality's "empty spool" option may come through as manual (state=1) + # with a placeholder material and no identifying metadata. Treat that as + # truly empty so UI/rendering does not show "OTHER". + empty_manual_signature = ( + raw_state_val == 1 + and rfid_missing + and not name_raw + and not vendor_raw + and mat_type_raw in ("", "-", "—", "–", "N/A", "NA", "NONE", "OTHER") + ) + state_val = 0 if empty_manual_signature else raw_state_val + selected = int(mat.get("selected") or 0) + + # state 2 = RFID: use Spoolman-based calc (same behavior as manual slots) + # state 1 = manual: WS always reports 100 (no sensor) → use Spoolman cache + # state 0 = empty: no percent + if state_val in (1, 2): + pct = manual_pct.get(slot) # None until async refresh fills it + else: + pct = None + + st.cfs_slots[slot] = { + "percent": pct, + "state": state_val, + "rfid": rfid_raw, + "selected": selected, + "present": state_val > 0, + "material": mat_type_raw if state_val > 0 else "", + "color": _normalize_color_hex(color_norm) if state_val > 0 else "", + "name": name_raw if state_val > 0 else "", + "manufacturer": vendor_raw if state_val > 0 else "", + } + seen_slots.add(slot) + + if selected == 1 and state_val > 0: + active_slot = slot + + # Update local slot metadata from WS data (only if a spool is physically present) + if state_val > 0 and slot in st.slots: + slot_obj = st.slots[slot] + if color_norm and len(color_norm) == 7 and color_norm.startswith("#"): + slot_obj.color_hex = color_norm + mat_type = (mat.get("type") or "").strip().upper() + if mat_type: + slot_obj.material = mat_type # type: ignore[assignment] + name = (mat.get("name") or "").strip() + if name: + slot_obj.name = name + vendor = (mat.get("vendor") or "").strip() + if vendor: + slot_obj.manufacturer = vendor + st.slots[slot] = slot_obj + + def _clear_slot_link(reason: str) -> None: + slot_obj_swap = st.slots.get(slot) + if slot_obj_swap and getattr(slot_obj_swap, "spoolman_id", None): + slot_obj_swap.spoolman_id = None + st.slots[slot] = slot_obj_swap + st.ws_slot_length_m.pop(slot, None) + last_rfid.pop(slot, None) + print(f"[CFS] ({printer_id}) Slot {slot}: {reason}, unlinked Spoolman spool") + + # Detect spool removal/swap and unlink Spoolman. + prev_state = last_state.get(slot, -1) + last_state[slot] = state_val + removed_or_swapped = (prev_state == 2 and state_val != 2) or (prev_state > 0 and state_val == 0) + if removed_or_swapped: + _clear_slot_link(f"state {prev_state}→{state_val}") + + # Detect manual filament metadata changes while state stays loaded. + if state_val > 0: + prev_fp = last_fingerprint.get(slot, "") + if prev_fp and slot_fingerprint and prev_fp != slot_fingerprint: + _clear_slot_link("filament metadata changed") + if slot_fingerprint: + last_fingerprint[slot] = slot_fingerprint + else: + last_fingerprint.pop(slot, None) + + # SSH serialNum-based auto-link is only available for CFS slots. + if allow_ssh_serial_lookup and state_val == 2 and prev_state != 2: + now = time.time() + if now - _ssh_last_fetch.get(printer_id, 0.0) > _SSH_FETCH_COOLDOWN: + asyncio.create_task(_ssh_fetch_and_apply(printer_id)) + + # 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 = last_rfid.get(slot, "") + if rfid != prev_rfid: + last_rfid[slot] = rfid + slot_obj2 = st.slots.get(slot) + 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, printer_id) + + # Track cumulative length for per-job Moonraker attribution + cur_m = float(mat.get("usedMaterialLength") or 0) + st.ws_slot_length_m[slot] = cur_m + + for box in boxes: + if not isinstance(box, dict): + continue + box_type = box.get("type") + if box_type == 0: + box_id = box.get("id") + if not isinstance(box_id, int) or box_id < 1 or box_id > 4: + continue - # Temperature / humidity are often strings like "32" and "31" - try: - tval = t.get("temperature") - hval = t.get("dry_and_humidity") - if tval is not None and str(tval).strip().lower() != "none": - boxes[bnum]["temperature_c"] = float(str(tval).strip()) - if hval is not None and str(hval).strip().lower() != "none": - boxes[bnum]["humidity_pct"] = float(str(hval).strip()) - except Exception: - pass + box_temp = float(box["temp"]) if isinstance(box.get("temp"), (int, float)) else None + box_humidity = float(box["humidity"]) if isinstance(box.get("humidity"), (int, float)) else None + boxes_meta[str(box_id)] = { + "connected": True, + "temperature_c": box_temp, + "humidity_pct": box_humidity, + } + _record_cfs_env_sample( + st, + box_id, + ts=now_ts, + temperature_c=box_temp, + humidity_pct=box_humidity, + ) - # If the box isn't connected, mark its slots as not present and continue. - if not is_conn: - for letter in ("A", "B", "C", "D"): - sid = f"{bnum}{letter}" - slots[sid] = {"present": False} + for mat in (box.get("materials") or []): + if not isinstance(mat, dict): continue - colors = t.get("color_value") - mats = t.get("material_type") - if not (isinstance(colors, list) and isinstance(mats, list)): + mat_id = mat.get("id") + if not isinstance(mat_id, int) or mat_id < 0 or mat_id > 3: continue - for idx, letter in enumerate(("A", "B", "C", "D")): - sid = f"{ti[1]}{letter}" # "1A".."4D" - raw_color = colors[idx] if idx < len(colors) else None - raw_mat = mats[idx] if idx < len(mats) else None - out: dict = {"present": True} - - # Creality uses "-1" to signal an empty slot - if isinstance(raw_mat, str) and raw_mat.strip() == "-1": - slots[sid] = {"present": False, "material": "", "color": ""} - continue - - col = _hex_color(str(raw_color)) if raw_color is not None else None - if col: - out["color"] = col - if isinstance(raw_mat, str): - out["material"] = mat_name_by_code.get(raw_mat, raw_mat).strip().upper() - - slots[sid] = out - - fil = t.get("filament") - if isinstance(fil, str) and fil in ("A", "B", "C", "D"): - active = f"{ti[1]}{fil}" - - if active is None and isinstance(rack, dict): - rc = rack.get("remain_material_color") - rt = rack.get("remain_material_type") - rc_hex = _hex_color(str(rc)) if rc is not None else None - rt_norm = mat_name_by_code.get(rt, rt).strip().upper() if isinstance(rt, str) else None - if rc_hex and rt_norm: - for sid, meta in slots.items(): - if meta.get("color") == rc_hex and meta.get("material") == rt_norm: - active = sid - break - - if slots: - mp = box.get("map") - if isinstance(mp, dict): - slots["_map"] = {"raw": mp} - # Add box connection metadata for the frontend - if boxes: - slots["_boxes"] = boxes - return active, slots - except Exception: - pass + slot = f"{box_id}{'ABCD'[mat_id]}" + if slot not in _VALID_CFS_SLOT_IDS: + continue + _process_material_slot(slot, mat, allow_ssh_serial_lookup=True) + continue - # 1) Direct keys - for k, v in (status or {}).items(): - if isinstance(k, str) and _SLOT_RE.match(k) and isinstance(v, dict): - slots[k] = v + if box_type == 1: + # Direct printer spool holder (single input, outside CFS boxes). + # Firmware sends this as a dedicated holder with one material entry. + mats = box.get("materials") or [] + first = mats[0] if isinstance(mats, list) and mats and isinstance(mats[0], dict) else {} + _process_material_slot(PRINTER_SPOOL_SLOT, first, allow_ssh_serial_lookup=False) + + # If the current payload did not include spool-holder data, keep SP visible but mark empty. + if PRINTER_SPOOL_SLOT not in seen_slots: + st.cfs_slots[PRINTER_SPOOL_SLOT] = { + "percent": None, + "state": 0, + "rfid": "", + "selected": 0, + "present": False, + "material": "", + "color": "", + "name": "", + "manufacturer": "", + } - # 2) Walk nested structures to find slot-like dicts - for p, v in _walk(status or {}): - if not isinstance(v, dict): + # Store box connection metadata so the frontend can show correct boxes. + # If no CFS boxes are present, clear stale metadata. + if boxes_meta: + st.cfs_slots["_boxes"] = boxes_meta + else: + st.cfs_slots.pop("_boxes", None) + + # 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 + else: + st.active_slot = None + + # Direct spool holder (SP) is not a CFS. Only mark connected when at least + # one CFS box (type 0) is present in the current payload. + st.cfs_connected = bool(boxes_meta) + st.cfs_last_update = _now() + st.printer_connected = True + st.printer_last_error = "" + + now = _now() + if now - _ws_last_save.get(printer_id, 0.0) >= _WS_SAVE_INTERVAL: + save_state(printer_id, st) + _ws_last_save[printer_id] = now + + +async def _refresh_manual_slot_pcts(printer_id: str) -> None: + """Calculate Spoolman-based percent for all linked slots (manual and RFID) and cache it. + + Called after each boxsInfo parse. Uses a per-slot TTL so Spoolman is queried + at most once per _SPOOLMAN_PCT_TTL seconds per slot. + """ + base = _spoolman_base_url() + if not base: + return + st = load_state(printer_id) + now = _now() + loop = asyncio.get_running_loop() + manual_pct = _spoolman_manual_pct.setdefault(printer_id, {}) + pct_refresh = _spoolman_pct_refresh_at.setdefault(printer_id, {}) + + for slot, cfs_meta in list(st.cfs_slots.items()): + if not isinstance(cfs_meta, dict) or cfs_meta.get("state") not in (1, 2): continue - # Active slot hints - for ak in ("active_slot", "current_slot", "slot", "cfs_slot", "ams_slot"): - if ak in v and isinstance(v[ak], str) and _SLOT_RE.match(v[ak]): - active = v[ak] - # Slot dictionaries keyed by slot id - if any(key in p.lower() for key in ("cfs", "ams", "mmu", "filament", "spool")): - for kk, vv in v.items(): - if isinstance(kk, str) and _SLOT_RE.match(kk) and isinstance(vv, dict): - slots.setdefault(kk, vv) - - # Normalize fields we care about - norm: dict[str, dict] = {} - for sid, raw in slots.items(): - if not isinstance(raw, dict): + slot_obj = st.slots.get(slot) + spool_id = getattr(slot_obj, "spoolman_id", None) if slot_obj else None + if not spool_id: + manual_pct.pop(slot, None) continue - out = {} - # presence / loaded flags - for pk in ("present", "loaded", "has_filament", "is_loaded", "enabled"): - if pk in raw and isinstance(raw[pk], (bool, int)): - out["present"] = bool(raw[pk]) - break - # material - for mk in ("material", "type", "filament_type"): - if mk in raw and isinstance(raw[mk], str): - out["material"] = raw[mk].strip().upper() - break - # color - for ck in ("color", "color_hex", "colour", "rgb"): - if ck in raw: - out["color"] = raw[ck] - break - # name/vendor - for nk in ("name", "label", "spool_name"): - if nk in raw and isinstance(raw[nk], str): - out["name"] = raw[nk] + if pct_refresh.get(slot, 0) > now: + continue # still fresh + + try: + sp = await loop.run_in_executor(None, _spoolman_get_spool, base, spool_id) + filament = sp.get("filament") or {} + nominal_g = float(filament.get("weight") or 0) + remaining_g = float(sp.get("remaining_weight") or 0) + used_g = float(sp.get("used_weight") or 0) + if nominal_g > 0: + pct: Optional[int] = max(0, min(100, int(round(remaining_g / nominal_g * 100)))) + elif remaining_g + used_g > 0: + pct = max(0, min(100, int(round(remaining_g / (remaining_g + used_g) * 100)))) + else: + pct = None + manual_pct[slot] = pct + pct_refresh[slot] = now + _SPOOLMAN_PCT_TTL + state_label = "RFID" if cfs_meta.get("state") == 2 else "manual" + print(f"[SPOOLMAN] ({printer_id}) Slot {slot} {state_label} percent: {pct}%") + except Exception: + pct_refresh[slot] = now + 10.0 # back off on error + + +async def _ws_connect_and_run(ws_url: str, printer_id: 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: + # Consume the very first burst (max 5 messages, 0.15 s each). + # Parse for printer identity (hostname/modelVersion) but skip CFS data, + # which may be stale at this point. + for _ in range(5): + try: + msg = await asyncio.wait_for(ws.recv(), timeout=0.15) + try: + _parse_ws_printer_info(json.loads(msg), printer_id) + except Exception: + pass + except asyncio.TimeoutError: break - for vk in ("vendor", "manufacturer", "brand"): - if vk in raw and isinstance(raw[vk], str): - out["manufacturer"] = raw[vk] + + # Heartbeat handshake. The printer may push status frames before "ok", + # so scan up to 10 messages instead of assuming the very next one is the ack. + await ws.send(json.dumps({"ModeCode": "heart_beat"})) + for _ in range(10): + try: + reply = await asyncio.wait_for(ws.recv(), timeout=2.0) + if str(reply).strip() == "ok": + break + except asyncio.TimeoutError: break - norm[sid] = out or {"raw": raw} + st = load_state(printer_id) + st.printer_connected = True + st.printer_last_error = "" + save_state(printer_id, st) + print(f"[WS] ({printer_id}) Connected to {ws_url}") + + # Request initial CFS data immediately after handshake + await ws.send(json.dumps({"method": "get", "params": {"boxsInfo": 1}})) + _last_request: float = asyncio.get_event_loop().time() + + # Continuous message loop — process everything the printer sends. + # Never assume the next recv() is the response to our request; the printer + # pushes status frames continuously between our request and its reply. + while True: + try: + msg = await asyncio.wait_for(ws.recv(), timeout=6.0) + except asyncio.TimeoutError: + # Printer went silent — re-request and wait again + await ws.send(json.dumps({"method": "get", "params": {"boxsInfo": 1}})) + _last_request = asyncio.get_event_loop().time() + continue + + # Printer heartbeat ping — ack it immediately + if isinstance(msg, str) and "heart_beat" in msg: + await ws.send("ok") + continue - return active, norm + # Plain "ok" is the printer acking our heartbeat — nothing to do + if isinstance(msg, str) and msg.strip() == "ok": + continue + try: + data = json.loads(msg) + _parse_ws_printer_info(data, printer_id) + if "boxsInfo" in data: + _parse_ws_cfs_data(data, printer_id) + asyncio.create_task(_refresh_manual_slot_pcts(printer_id)) + except Exception: + pass + # Re-request every 5 s so we keep receiving fresh pushes + now = asyncio.get_event_loop().time() + if now - _last_request >= 5.0: + await ws.send(json.dumps({"method": "get", "params": {"boxsInfo": 1}})) + _last_request = now -async def moonraker_poll_loop() -> None: - cfg = load_config() - base = (cfg.get("moonraker_url") or "").strip() - if not base: +async def printer_ws_loop(printer_id: str) -> None: + """Outer reconnect loop for the printer WebSocket connection.""" + ws_url = _printer_ws_url(printer_id) + if not ws_url: + print(f"[WS] ({printer_id}) No printer ID configured — WebSocket loop not started.") return - interval = float(cfg.get("poll_interval_sec", 5) or 5) - if interval < 1: - interval = 1 + print(f"[WS] ({printer_id}) Starting WebSocket loop for {ws_url}") + backoff = 2.0 - # Always query job usage - base_objects = ["print_stats", "virtual_sdcard"] + while True: + last_err = "" + try: + await _ws_connect_and_run(ws_url, printer_id) + backoff = 2.0 # reset on clean exit + except Exception as e: + last_err = str(e) + print(f"[WS] ({printer_id}) Connection lost: {e}") - # Best-effort: discover CFS-related objects once, then include them in polling. - cfs_objects: list[str] = [] - try: - objs = await asyncio.to_thread(_moonraker_list_objects, base) - for o in objs: - lo = str(o).lower() - if any(x in lo for x in ("cfs", "ams", "mmu", "spool", "filament_box", "filamentbox")): - cfs_objects.append(str(o)) - # Creality K-series / K2 Plus objects - if lo in ("box", "filament_rack"): - cfs_objects.append(str(o)) - # Keep the poll URL reasonably short - cfs_objects = cfs_objects[:12] - except Exception: - cfs_objects = [] + try: + st = load_state(printer_id) + st.printer_connected = False + st.cfs_connected = False + st.printer_last_error = last_err + save_state(printer_id, st) + except Exception: + pass - poll_objects = base_objects + cfs_objects - url = _moonraker_build_url(base, poll_objects) + print(f"[WS] ({printer_id}) Reconnecting in {backoff:.0f}s…") + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 60.0) - # Optional: if enabled, we import material/color/name from CFS objects into our local slots. - cfs_autosync = bool(cfg.get("cfs_autosync", False)) - # Pull Moonraker's global history occasionally (read-only). - last_hist_fetch = 0.0 - hist_every_sec = 60.0 +def _moon_history_job_exists(history: list, *, moon_job_id: str, ended_at: float, job_name: str) -> bool: + """Return True if job_history already contains this Moonraker job.""" + ended_ts = float(ended_at or 0.0) + name_norm = str(job_name or "").strip() + mid_norm = str(moon_job_id or "").strip() - while True: + for item in history: + if not isinstance(item, dict): + continue + if mid_norm and str(item.get("moon_job_id") or "").strip() == mid_norm: + return True try: - data = await asyncio.to_thread(_http_get_json, url) - status = (((data or {}).get("result") or {}).get("status") or {}) - ps = status.get("print_stats") or {} - vsd = status.get("virtual_sdcard") or {} - - ps_state = str(ps.get("state") or "").lower() - - filename = ps.get("filename") or vsd.get("file_path") or "" - if isinstance(filename, str) and "/" in filename: - filename = filename.rsplit("/", 1)[-1] - used = ps.get("filament_used") - if used is None: - used_mm = 0 - else: - used_mm = int(float(used)) + item_ended = float(item.get("ended_at") or 0.0) + except Exception: + item_ended = 0.0 + if ended_ts <= 0 or item_ended <= 0: + continue + if abs(item_ended - ended_ts) <= 1.0 and str(item.get("job_name") or "").strip() == name_norm: + return True + return False + + +def _moon_sync_missing_history_jobs(printer_id: str, base: str) -> None: + """Backfill jobs that completed while CFSync was offline or unhealthy.""" + history = _job_history_fetch(printer_id) + + latest_end = 0.0 + for item in history: + if not isinstance(item, dict): + continue + try: + latest_end = max(latest_end, float(item.get("ended_at") or 0.0)) + except Exception: + pass + + since_ts = max(0.0, latest_end - _MOON_HISTORY_OVERLAP_SECS) + changed = False + start = 0 + while True: + url = f"{base}/server/history/list?limit={_MOON_HISTORY_PAGE_LIMIT}&start={start}&order=asc" + if since_ts > 0: + url += f"&since={since_ts:.3f}" + data = _http_get_json(url, timeout=6.0) + payload = (data.get("result") or data) if isinstance(data, dict) else {} + jobs = payload.get("jobs") or [] + if not isinstance(jobs, list) or not jobs: + break + + for job in jobs: + if not isinstance(job, dict): + continue + status = str(job.get("status") or "").strip().lower() + if status not in _MOON_HISTORY_TERMINAL_STATES: + continue - used_g = 0.0 try: - meta = ((vsd.get("cur_print_data") or {}).get("metadata") or {}) - lst = meta.get("filament_used_g") - if isinstance(lst, list) and lst: - used_g = float(sum(float(x) for x in lst if x is not None)) + ended_at = float(job.get("end_time") or 0.0) except Exception: - used_g = 0.0 - - st = load_state() - st.printer_connected = True - st.printer_last_error = "" - - # --- CFS read-only extraction (best effort) --- - cfs_status = {k: v for k, v in (status or {}).items() if k not in ("print_stats", "virtual_sdcard")} - if cfs_status: - active_slot, slots_meta = _extract_cfs_slot_data(cfs_status) - st.cfs_connected = True - st.cfs_last_update = _now() - st.cfs_active_slot = active_slot - st.cfs_slots = slots_meta - # store a small raw snapshot for debugging in the UI - st.cfs_raw = {k: cfs_status[k] for k in list(cfs_status)[:4]} - - # If the printer reports an active slot, we can reflect it locally (no POST to printer) - if active_slot and active_slot in st.slots: - st.active_slot = active_slot - - # Optional: import metadata into local slots (still read-only to printer) - if cfs_autosync and slots_meta: - for sid, meta in slots_meta.items(): - if sid not in st.slots: - continue - s = st.slots[sid] - mat = meta.get("material") - if isinstance(mat, str) and mat.strip(): - # unknown material will be normalized to OTHER by schema - s.material = mat.strip().upper() # type: ignore - col = meta.get("color") - if isinstance(col, str) and col.startswith("#") and len(col) == 7: - s.color_hex = col.lower() - name = meta.get("name") - if isinstance(name, str): - s.name = name - mfg = meta.get("manufacturer") - if isinstance(mfg, str): - s.manufacturer = mfg - st.slots[sid] = s - else: - st.cfs_connected = False + ended_at = 0.0 + if ended_at <= 0: + continue + + moon_job_id = str(job.get("job_id") or job.get("uid") or "").strip() + job_name = str(job.get("filename") or job.get("job_name") or "").strip() + if _moon_history_job_exists(history, moon_job_id=moon_job_id, ended_at=ended_at, job_name=job_name): + continue - # --- Per-slot history tracking (read-only) --- - # Attribute delta filament_used(mm) to the currently active slot during a print. - # This enables per-slot history (and later accurate remaining_g calculations) even - # for multi-color prints. try: - is_printing = ps_state in ("printing", "paused") - tracking = bool(st.job_track_name) - curr_slot = (st.cfs_active_slot or st.active_slot or "").strip() - - # Start tracking when a print begins - if is_printing and filename: - if (not tracking) or (st.job_track_name != filename): - st.job_track_name = filename - st.job_track_started_at = _now() - st.job_track_last_mm = 0 - st.job_track_slot_mm = {} - st.job_track_slot_g = {} - st.job_track_last_state = ps_state - - # Attribute delta to current slot - last_mm = int(st.job_track_last_mm or 0) - delta_mm = max(0, int(used_mm) - last_mm) - if delta_mm > 0 and curr_slot: - st.job_track_slot_mm[curr_slot] = int(st.job_track_slot_mm.get(curr_slot, 0)) + int(delta_mm) - - # Convert delta_mm to grams for this slot's material and track it - try: - mat = st.slots.get(curr_slot).material if curr_slot in st.slots else "OTHER" - g_delta = float(mm_to_g(str(mat), float(delta_mm))) - except Exception: - g_delta = 0.0 - if g_delta > 0: - st.job_track_slot_g[curr_slot] = float(st.job_track_slot_g.get(curr_slot, 0.0)) + float(g_delta) - - # Live spool deduction: increment epoch-consumed total immediately - _inc_slot_epoch_consumed(st, curr_slot, float(g_delta)) - st.job_track_last_mm = int(used_mm) - st.job_track_last_state = ps_state - - # Publish a single "live" history entry per slot for the current job. - # This makes the right-hand "Historie pro Slot" useful during - # multi-color prints (usage is attributed while printing, not only at the end). - try: - now_ts = _now() - slot_mm_live = st.job_track_slot_mm if isinstance(st.job_track_slot_mm, dict) else {} - for sid, mm_live in slot_mm_live.items(): - try: - mm_i = int(mm_live or 0) - if mm_i <= 0: - continue - mat = st.slots.get(sid).material if sid in st.slots else "OTHER" - g_live = float(round(mm_to_g(str(mat), float(mm_i)), 2)) - src = f"live:{st.job_track_started_at}:{st.job_track_name}:{sid}" - _hist_upsert_by_src( - st, - sid, - src, - { - "ts": float(now_ts), - "job": st.job_track_name, - "used_mm": mm_i, - "used_g": g_live, - "result": "printing", - }, - ) - except Exception: - continue - except Exception: - pass - - # Finalize when printing ends (complete/cancel/error/standby) - if (not is_printing) and tracking and st.job_track_name: - # Determine an end timestamp from Creality virtual_sdcard if available - end_ts = _now() - try: - cpd = (vsd.get("cur_print_data") or {}) - et = cpd.get("end_time") - if et is not None: - end_ts = float(et) - except Exception: - pass - - # Create history entries per slot (only if we have consumption) - slot_mm = st.job_track_slot_mm if isinstance(st.job_track_slot_mm, dict) else {} - for sid, mm in slot_mm.items(): - try: - mm_i = int(mm) - if mm_i <= 0: - continue - mat = st.slots.get(sid).material if sid in st.slots else "OTHER" - g = float(round(mm_to_g(str(mat), float(mm_i)), 2)) - - # Remove any live entry for this job/slot (so we don't show duplicates) - try: - live_src = f"live:{st.job_track_started_at}:{st.job_track_name}:{sid}" - h0 = st.slot_history.get(sid) - if isinstance(h0, list): - st.slot_history[sid] = [e for e in h0 if not (isinstance(e, dict) and e.get("_src") == live_src)] - except Exception: - pass - - _hist_push( - st, - sid, - { - "ts": float(end_ts), - "job": st.job_track_name, - "used_mm": mm_i, - "used_g": g, - "result": ps_state, - }, - ) - except Exception: - continue - - # Reset tracking - st.job_track_name = "" - st.job_track_started_at = 0.0 - st.job_track_last_mm = 0 - st.job_track_slot_mm = {} - st.job_track_slot_g = {} - st.job_track_last_state = ps_state + filament_mm = max(0.0, float(job.get("filament_used") or 0.0)) except Exception: - pass - - # --- Job usage accounting --- - if filename or used_mm: - _apply_job_usage(st, filename or st.current_job or "", used_mm) - if used_g > 0.0: - st.current_job_filament_g = float(round(used_g, 2)) + filament_mm = 0.0 + if filament_mm <= 0: + continue - # --- Moonraker history snapshot (global) --- - # This is useful to show past jobs even if our per-slot tracker - # wasn't running. It won't reliably attribute usage to CFS slots, - # so the UI shows it separately. try: - now = _now() - if (now - last_hist_fetch) >= hist_every_sec: - hist = await asyncio.to_thread(_moonraker_fetch_history, base, 20) - if hist: - st.moonraker_history = hist - last_hist_fetch = now + started_at = float(job.get("start_time") or 0.0) except Exception: - pass + started_at = 0.0 + if started_at <= 0 or started_at > ended_at: + started_at = ended_at - save_state(st) - except Exception as e: - st = load_state() - st.printer_connected = False - st.printer_last_error = str(e) - st.updated_at = time.time() - save_state(st) + grams = mm_to_g("OTHER", filament_mm) + if grams <= 0: + continue + meters = filament_mm / 1000.0 + + job_entry = { + "printer_id": printer_id, + "job_name": job_name, + "reason": f"Recovered job {status}", + "started_at": started_at, + "ended_at": ended_at, + "spools": [{ + "slot": "UNKNOWN", + "spoolman_id": None, + "material": "OTHER", + "name": "", + "manufacturer": "", + "color_hex": "", + "grams": round(grams, 2), + "meters": round(meters, 4), + "needs_link": True, + }], + "total_grams": round(grams, 2), + "total_meters": round(meters, 4), + "source": "moonraker_history", + "moon_job_id": moon_job_id, + "needs_link": True, + } + inserted = _job_history_insert(printer_id, job_entry, dedupe=True) + if inserted: + history.append(job_entry) + changed = True + print( + f"[MOON] ({printer_id}) Recovered missed job " + f"{moon_job_id or ''} ({status}, {filament_mm:.1f}mm)" + ) + + if len(jobs) < _MOON_HISTORY_PAGE_LIMIT: + break + start += len(jobs) + # Hard cap to keep one sync cycle bounded. + if start >= (_MOON_HISTORY_PAGE_LIMIT * 10): + break + + if changed: + history.sort(key=lambda x: float((x or {}).get("ended_at") or 0.0)) + + +def _moon_flush_to_spoolman( + printer_id: str, + reason: str, + *, + started_at: Optional[float] = None, + ended_at: Optional[float] = None, + job_name: str = "", +) -> None: + """Sync accumulated per-slot grams to Spoolman, persist job stats, and reset trackers.""" + st = load_state(printer_id) + job_g = _moon_job_track_slot_g.setdefault(printer_id, {}) + job_mm = _moon_job_track_slot_mm.setdefault(printer_id, {}) + ended_ts = ended_at or _now() + started_ts = started_at or ended_ts + spools: List[dict] = [] + total_grams = 0.0 + total_meters = 0.0 + spoolman_base = _spoolman_base_url() + spool_color_cache: Dict[int, str] = {} + + for slot, g in job_g.items(): + if g <= 0: + continue + slot_obj = st.slots.get(slot) + spool_id = getattr(slot_obj, "spoolman_id", None) if slot_obj else None + spool_id_norm = _spoolman_id_or_none(spool_id) + color_hex = _normalize_color_hex(str(getattr(slot_obj, "color_hex", "") or "")) + if spool_id_norm and spoolman_base: + if spool_id_norm not in spool_color_cache: + spool_color_cache[spool_id_norm] = "" + try: + spool_data = _spoolman_get_spool(spoolman_base, spool_id_norm) + filament = spool_data.get("filament") or {} + spool_color_cache[spool_id_norm] = _normalize_color_hex(str(filament.get("color_hex") or "")) + except Exception as e: + print(f"[MOON] ({printer_id}) spool color lookup failed for spool {spool_id_norm}: {e}") + if spool_color_cache.get(spool_id_norm): + color_hex = spool_color_cache[spool_id_norm] + meters = max(0.0, float(job_mm.get(slot, 0.0) or 0.0) / 1000.0) + total_grams += g + total_meters += meters + spools.append({ + "slot": slot, + "spoolman_id": spool_id, + "material": str(getattr(slot_obj, "material", "") or ""), + "name": str(getattr(slot_obj, "name", "") or ""), + "manufacturer": str(getattr(slot_obj, "manufacturer", "") or ""), + "color_hex": color_hex, + "grams": round(g, 2), + "meters": round(meters, 4), + }) + if spool_id: + _spoolman_report_usage(spool_id, g) + print(f"[MOON] ({printer_id}) {reason}: slot {slot} → {g:.2f}g synced to Spoolman spool {spool_id}") + else: + print(f"[MOON] ({printer_id}) {reason}: slot {slot} → {g:.2f}g (no Spoolman link, not synced)") + if not job_g: + print(f"[MOON] ({printer_id}) {reason}: no filament deltas recorded") + + # Persist lifetime stats for each slot that consumed filament this job + now = _now() + for slot, g in job_g.items(): + if g <= 0: + continue + stats = st.cfs_stats.get(slot) or SlotStats() + stats.total_kg = round(stats.total_kg + g / 1000.0, 6) + stats.total_meters = round(stats.total_meters + job_mm.get(slot, 0.0) / 1000.0, 4) + stats.last_used_at = now + st.cfs_stats[slot] = stats + _job_history_insert(printer_id, { + "printer_id": printer_id, + "job_name": job_name, + "reason": reason, + "started_at": started_ts, + "ended_at": ended_ts, + "spools": spools, + "total_grams": round(total_grams, 2), + "total_meters": round(total_meters, 4), + }, dedupe=False) + + if any(g > 0 for g in job_g.values()): + save_state(printer_id, st) + + _moon_job_track_slot_g[printer_id] = {} + _moon_job_track_slot_mm[printer_id] = {} + _moon_last_filament_mm[printer_id] = 0.0 + + +async def moonraker_job_poll_loop(printer_id: str) -> None: + """Poll Moonraker print_stats every 5s; attribute each filament delta to the active slot.""" + base = _moonraker_base_url(printer_id) + if not base: + print(f"[MOON] ({printer_id}) No printer URL configured — job poll loop not started.") + return + + print(f"[MOON] ({printer_id}) Starting job poll loop against {base}") + + _ACTIVE_STATES = {"printing", "paused"} - await asyncio.sleep(interval) + def _resolve_tracking_slot(st: AppState) -> Optional[str]: + # Prefer the live slot reported by WS when available. + if st.cfs_active_slot and st.cfs_active_slot in st.slots: + return st.cfs_active_slot + + # Printers without CFS may not report "selected". In that case, + # use direct spool input when it is present. + cfs_slots = st.cfs_slots if isinstance(st.cfs_slots, dict) else {} + sp_meta = cfs_slots.get(PRINTER_SPOOL_SLOT) if isinstance(cfs_slots, dict) else None + sp_present = isinstance(sp_meta, dict) and bool(sp_meta.get("present", False)) + if sp_present and not bool(st.cfs_connected) and PRINTER_SPOOL_SLOT in st.slots: + return PRINTER_SPOOL_SLOT + + # Final fallback: legacy active slot. + if st.active_slot and st.active_slot in st.slots: + return st.active_slot + return None + + 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) + job_name = str(ps.get("filename") or ps.get("job_name") or "").strip() + + prev = _moon_last_state.get(printer_id, "") + _moon_last_state[printer_id] = new_state + + if new_state in _ACTIVE_STATES and prev not in _ACTIVE_STATES: + # Job started — reset trackers + _moon_job_track_slot_g[printer_id] = {} + _moon_job_track_slot_mm[printer_id] = {} + _moon_last_filament_mm[printer_id] = filament_used_mm + _moon_job_started_at[printer_id] = _now() + _moon_job_name[printer_id] = job_name + print(f"[MOON] ({printer_id}) State: {prev!r} → {new_state!r}; tracking filament deltas per active slot") + + elif new_state in _ACTIVE_STATES: + if job_name: + _moon_job_name[printer_id] = job_name + # Still printing/paused — attribute delta to currently active slot + delta_mm = max(0.0, filament_used_mm - _moon_last_filament_mm.get(printer_id, 0.0)) + _moon_last_filament_mm[printer_id] = filament_used_mm + if delta_mm > 0: + st = load_state(printer_id) + curr_slot = _resolve_tracking_slot(st) + if curr_slot and curr_slot in st.slots: + mat_str = str(getattr(st.slots[curr_slot], "material", "OTHER") or "OTHER") + g = mm_to_g(mat_str, delta_mm) + if g > 0: + job_g = _moon_job_track_slot_g.setdefault(printer_id, {}) + job_mm = _moon_job_track_slot_mm.setdefault(printer_id, {}) + job_g[curr_slot] = job_g.get(curr_slot, 0.0) + g + job_mm[curr_slot] = job_mm.get(curr_slot, 0.0) + delta_mm + + elif new_state in {"complete", "error", "cancelled"} and prev in _ACTIVE_STATES: + # Capture any final delta, then flush accumulated grams to Spoolman + delta_mm = max(0.0, filament_used_mm - _moon_last_filament_mm.get(printer_id, 0.0)) + if delta_mm > 0: + st = load_state(printer_id) + curr_slot = _resolve_tracking_slot(st) + if curr_slot and curr_slot in st.slots: + mat_str = str(getattr(st.slots[curr_slot], "material", "OTHER") or "OTHER") + g = mm_to_g(mat_str, delta_mm) + if g > 0: + job_g = _moon_job_track_slot_g.setdefault(printer_id, {}) + job_mm = _moon_job_track_slot_mm.setdefault(printer_id, {}) + job_g[curr_slot] = job_g.get(curr_slot, 0.0) + g + job_mm[curr_slot] = job_mm.get(curr_slot, 0.0) + delta_mm + print(f"[MOON] ({printer_id}) State: {prev!r} → {new_state!r}; {filament_used_mm:.0f}mm total filament used") + _moon_flush_to_spoolman( + printer_id, + f"Job {new_state}", + started_at=_moon_job_started_at.get(printer_id), + ended_at=_now(), + job_name=_moon_job_name.get(printer_id, job_name), + ) + _moon_job_started_at.pop(printer_id, None) + _moon_job_name.pop(printer_id, None) + + now_ts = _now() + if now_ts - _moon_history_last_sync.get(printer_id, 0.0) >= _MOON_HISTORY_SYNC_INTERVAL: + _moon_sync_missing_history_jobs(printer_id, base) + _moon_history_last_sync[printer_id] = now_ts + + except Exception: + # Network errors are expected when printer is off — don't log verbosely + pass +app = FastAPI(title="CFSync", version="0.1.1") -app = FastAPI(title="3D Drucker Filament Manager", version="0.1.1") +# Allow the Fluidd panel bookmarklet to fetch from a different origin (local network only) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["GET", "POST"], + allow_headers=["*"], +) @app.middleware("http") @@ -1029,9 +2355,13 @@ async def _no_cache_static(request: Request, call_next): @app.on_event("startup") async def _startup(): _ensure_data_files() - cfg = load_config() - if (cfg.get("moonraker_url") or "").strip(): - asyncio.create_task(moonraker_poll_loop()) + _jobdb_bootstrap_if_needed() + printer_ids = [str((p or {}).get("id") or "") for p in (load_config().get("printers") or []) if (p or {}).get("id")] + if not printer_ids: + print("[BOOT] No printers configured — waiting for data/config.json") + for pid in printer_ids: + asyncio.create_task(printer_ws_loop(pid)) + asyncio.create_task(moonraker_job_poll_loop(pid)) @app.get("/") @@ -1039,89 +2369,27 @@ def index(): return FileResponse(str(STATIC_DIR / "index.html")) -# --- Public API --- -@app.get("/api/state", response_model=AppState) -def api_state(): - return load_state() - - -@app.post("/api/moonraker/allocate", response_model=AppState) -def api_moonraker_allocate(req: MoonrakerAllocateRequest): - """Store local per-slot allocation for a Moonraker history job. - - This never talks to the printer. It only enriches our local per-slot history. - """ - st = load_state() - key = (req.job_key or "").strip() or _job_key(req.job_key, req.ts, req.job) +@app.get("/jobs") +def jobs_page(): + return FileResponse(str(STATIC_DIR / "jobs.html")) - # Normalize alloc_g: drop zeros/negatives - alloc: Dict[str, float] = {} - for sid, g in (req.alloc_g or {}).items(): - try: - gv = float(g) - if gv > 0: - alloc[str(sid)] = float(round(gv, 2)) - except Exception: - continue - if not alloc: - raise HTTPException(status_code=400, detail="alloc_g must contain at least one positive value") - - # Persist allocation - st.moonraker_allocations[key] = {"job": req.job, "ts": float(req.ts), "alloc_g": alloc} - - # Push entries into per-slot history (and replace previous pushes for this key) - # We keep a marker so we can de-duplicate. - marker = f"moonraker:{key}" - for sid in alloc.keys(): - h = st.slot_history.get(sid) - if isinstance(h, list): - # Remove previous entries for this marker and adjust epoch totals accordingly. - new_h = [] - removed_g = 0.0 - for e in h: - if isinstance(e, dict) and e.get("_src") == marker: - try: - removed_g += float(e.get("used_g") or 0.0) - except Exception: - pass - continue - new_h.append(e) - st.slot_history[sid] = new_h - if removed_g > 0: - try: - s = st.slots.get(sid) - if s: - # Only subtract from current epoch total if the marker entries - # were added in the current epoch. - # (Older epochs should not affect current totals.) - # We approximate by checking the current slot epoch matches the - # epoch on the first removed entry if available. - s.spool_epoch_consumed_g_total = max(0.0, float(getattr(s, "spool_epoch_consumed_g_total", 0.0) or 0.0) - float(removed_g)) - st.slots[sid] = s - except Exception: - pass - - for sid, g in alloc.items(): - _hist_push( - st, - sid, - { - "ts": float(req.ts), - "job": req.job, - "used_mm": 0, - "used_g": float(round(float(g), 2)), - "result": "history", - "_src": marker, - }, - ) - _inc_slot_epoch_consumed(st, sid, float(g)) - - save_state(st) +# --- Public API --- +@app.get("/api/state", response_model=AppState) +def api_state(printer_id: Optional[str] = None): + pid = _resolve_printer_id(printer_id, allow_unknown=printer_id is None) + st = load_state(pid) + st.job_history = _job_history_fetch(pid) return st -def _ui_state_dict(state: AppState) -> dict: + +def _ui_state_dict( + state: AppState, + *, + printer_id: Optional[str] = None, + job_limit: Optional[int] = 10, +) -> dict: """Convert internal AppState to the UI payload the static frontend expects.""" d = _model_dump(state) slots_in = d.get("slots", {}) or {} @@ -1134,116 +2402,185 @@ def _ui_state_dict(state: AppState) -> dict: out["color"] = out.pop("color_hex") if "manufacturer" in out and "vendor" not in out: out["vendor"] = out.get("manufacturer", "") - - # Derived spool metrics (purely local) - # - spool_consumed_g: running total for current epoch (stable even if UI history is trimmed) - # - spool_used_g: consumption since the last "Übernehmen" reference - # - spool_remaining_g: computed remaining weight - try: - consumed = float(out.get("spool_epoch_consumed_g_total") or 0.0) - out["spool_consumed_g"] = round(consumed, 2) - - ref_rem = out.get("spool_ref_remaining_g") - ref_cons = out.get("spool_ref_consumed_g") - if ref_rem is not None and ref_cons is not None: - # Remaining decreases only by consumption since reference point - since = max(0.0, consumed - float(ref_cons)) - remaining = max(0.0, float(ref_rem) - since) - out["spool_remaining_g"] = round(remaining, 1) - out["spool_used_g"] = round(since, 1) - except Exception: - pass - slots_out[slot_id] = out d["slots"] = slots_out - # UI expects job info as flat fields - d.setdefault("current_job", "") - d.setdefault("current_job_filament_mm", 0) - d.setdefault("current_job_filament_g", 0.0) - - # printer connection info for header badge d.setdefault("printer_connected", False) d.setdefault("printer_last_error", "") - d.setdefault("cfs_connected", False) d.setdefault("cfs_last_update", 0.0) d.setdefault("cfs_active_slot", None) d.setdefault("cfs_slots", {}) - d.setdefault("cfs_raw", {}) + d.setdefault("cfs_stats", {}) + d.setdefault("cfs_env_history", {}) + if printer_id: + d["job_history"] = _job_history_fetch(printer_id, limit=job_limit) + else: + d.setdefault("job_history", []) + d["job_history"] = _ui_hydrate_job_history_colors(d["job_history"]) + d["spoolman_configured"] = bool(_spoolman_base_url()) + d["spoolman_url"] = _spoolman_base_url() return d -def _slot_consumed_g_epoch(state: AppState, slot: str) -> float: - try: - s = state.slots.get(slot) - return float(getattr(s, "spool_epoch_consumed_g_total", 0.0) or 0.0) - except Exception: - return 0.0 - - # --- UI API (static frontend uses /api/ui/* and expects {"result": ...}) --- @app.get("/api/ui/state", response_model=ApiResponse) def api_ui_state() -> ApiResponse: - return ApiResponse(result=_ui_state_dict(load_state())) - - -@app.post("/api/ui/moonraker/allocate", response_model=ApiResponse) -def api_ui_moonraker_allocate(req: MoonrakerAllocateRequest) -> ApiResponse: - st = api_moonraker_allocate(req) - return ApiResponse(result=_ui_state_dict(st)) + printers_out = [] + for pid in _all_printer_ids(): + st = load_state(pid) + d = _ui_state_dict(st, printer_id=pid) + d["printer_id"] = pid + printers_out.append({"id": pid, "state": d}) + return ApiResponse(result={ + "printers": printers_out, + "spoolman_configured": bool(_spoolman_base_url()), + "spoolman_url": _spoolman_base_url(), + }) + + +@app.get("/api/ui/jobs") +def api_ui_jobs( + printer_id: str = "", + material: str = "", + spoolman_id: str = "", + needs_link: str = "", + q: str = "", + from_ts: str = "", + to_ts: str = "", + limit: int = 50, + offset: int = 0, +): + pid = "" + if str(printer_id or "").strip(): + pid = _resolve_printer_id(str(printer_id), allow_unknown=False) + + sid = _spoolman_id_or_none(spoolman_id) + + needs_link_norm: Optional[bool] = None + nl_raw = str(needs_link or "").strip().lower() + if nl_raw in ("1", "true", "yes", "y"): + needs_link_norm = True + elif nl_raw in ("0", "false", "no", "n"): + needs_link_norm = False + + from_val: Optional[float] = None + to_val: Optional[float] = None + try: + if str(from_ts or "").strip(): + from_val = float(from_ts) + except Exception: + from_val = None + try: + if str(to_ts or "").strip(): + to_val = float(to_ts) + except Exception: + to_val = None + + items, total = _job_history_query( + printer_id=pid or None, + material=str(material or "").strip().upper(), + spoolman_id=sid, + needs_link=needs_link_norm, + q=q, + from_ts=from_val, + to_ts=to_val, + limit=limit, + offset=offset, + ) + items = _ui_hydrate_job_history_colors(items) + return { + "items": items, + "total": total, + "limit": max(1, min(200, int(limit))), + "offset": max(0, int(offset)), + "filters": { + "printer_id": pid, + "material": str(material or "").strip().upper(), + "spoolman_id": sid, + "needs_link": needs_link_norm, + "q": str(q or "").strip(), + "from_ts": from_val, + "to_ts": to_val, + }, + "options": _job_history_filter_options(), + } + + +@app.get("/api/printers") +def api_printers(): + printers = [] + for pid in _all_printer_ids(): + st = load_state(pid) + printers.append({ + "id": pid, + "address": _printer_address(pid), + "name": st.printer_name, + "firmware": st.printer_firmware, + "connected": st.printer_connected, + "cfs_connected": st.cfs_connected, + "last_error": st.printer_last_error, + }) + return {"printers": printers} @app.post("/api/select_slot", response_model=AppState) def api_select_slot(req: SelectSlotRequest): - state = load_state() + pid = _resolve_printer_id(req.printer_id, allow_unknown=req.printer_id is None) + state = load_state(pid) if req.slot not in state.slots: raise HTTPException(status_code=404, detail="Unknown slot") state.active_slot = req.slot - save_state(state) + save_state(pid, state) return state @app.post("/api/ui/select_slot", response_model=ApiResponse) def api_ui_select_slot(req: SelectSlotRequest) -> ApiResponse: + pid = _resolve_printer_id(req.printer_id, allow_unknown=req.printer_id is None) state = api_select_slot(req) - return ApiResponse(result=_ui_state_dict(state)) + return ApiResponse(result=_ui_state_dict(state, printer_id=pid)) @app.post("/api/set_auto", response_model=AppState) def api_set_auto(req: SetAutoRequest): - state = load_state() + pid = _resolve_printer_id(req.printer_id, allow_unknown=req.printer_id is None) + state = load_state(pid) state.auto_mode = bool(req.enabled) - save_state(state) + save_state(pid, state) return state @app.post("/api/ui/set_auto", response_model=ApiResponse) def api_ui_set_auto(req: SetAutoRequest) -> ApiResponse: + pid = _resolve_printer_id(req.printer_id, allow_unknown=req.printer_id is None) state = api_set_auto(req) - return ApiResponse(result=_ui_state_dict(state)) + return ApiResponse(result=_ui_state_dict(state, printer_id=pid)) @app.patch("/api/slots/{slot}", response_model=AppState) def api_update_slot(slot: str, req: UpdateSlotRequest): - state = load_state() + pid = _resolve_printer_id(req.printer_id, allow_unknown=req.printer_id is None) + state = load_state(pid) if slot not in state.slots: raise HTTPException(status_code=404, detail="Unknown slot") s = state.slots[slot] update = _req_dump(req, exclude_unset=True) for k, v in update.items(): - setattr(s, k, v) + if hasattr(s, k): + setattr(s, k, v) state.slots[slot] = s - save_state(state) + save_state(pid, state) return state @app.post("/api/ui/slot/update", response_model=ApiResponse) def api_ui_slot_update(req: UiSlotUpdateRequest) -> ApiResponse: - state = load_state() + pid = _resolve_printer_id(req.printer_id, allow_unknown=req.printer_id is None) + state = load_state(pid) slot = req.slot if slot not in state.slots: raise HTTPException(status_code=404, detail="Unknown slot") @@ -1268,145 +2605,322 @@ def api_ui_slot_update(req: UiSlotUpdateRequest) -> ApiResponse: setattr(s, k, v) state.slots[slot] = s - save_state(state) - return ApiResponse(result=_ui_state_dict(state)) + save_state(pid, state) + return ApiResponse(result=_ui_state_dict(state, printer_id=pid)) -@app.post("/api/ui/slot/reset", response_model=ApiResponse) -def api_ui_slot_reset(req: UiSlotResetRequest) -> ApiResponse: - state = load_state() - slot = req.slot - if slot not in state.slots: - raise HTTPException(status_code=404, detail="Unknown slot") - state.slots[slot].remaining_g = float(req.remaining_g) - save_state(state) - return ApiResponse(result=_ui_state_dict(state)) - @app.post("/api/ui/spool/set_start", response_model=ApiResponse) def api_ui_spool_set_start(req: UiSpoolSetStartRequest) -> ApiResponse: - """Roll change: set new spool baseline (local only). - - Historical entries are kept, but hidden by incrementing the slot's spool_epoch. - The new spool's remaining weight is set as the reference point. - """ - state = load_state() + """Roll change: increment epoch and auto-unlink Spoolman spool.""" + pid = _resolve_printer_id(req.printer_id, allow_unknown=req.printer_id is None) + state = load_state(pid) slot = req.slot if slot not in state.slots: raise HTTPException(status_code=404, detail="Unknown slot") - start_g = float(req.start_g) s = state.slots[slot] - # New roll => new epoch + # New roll => new epoch (hides old history in Spoolman status, triggers auto-unlink) try: s.spool_epoch = int(getattr(s, "spool_epoch", 0) or 0) + 1 except Exception: s.spool_epoch = 1 - - # Reset accounting for the new epoch - s.spool_epoch_consumed_g_total = 0.0 - s.spool_ref_remaining_g = start_g - s.spool_ref_consumed_g = 0.0 - s.spool_ref_set_at = time.time() - # keep legacy fields for debugging only - s.spool_start_g = start_g - s.remaining_g = start_g + # Roll change auto-unlinks Spoolman spool + s.spoolman_id = None state.slots[slot] = s - save_state(state) - return ApiResponse(result=_ui_state_dict(state)) + # Reset WS length baseline so next snapshot doesn't trigger a false delta + state.ws_slot_length_m.pop(slot, None) + # Clear RFID/state cache so re-inserting any spool triggers auto-link again + _ws_last_rfid.setdefault(pid, {}).pop(slot, None) + _ws_last_state.setdefault(pid, {}).pop(slot, None) + _ws_last_fingerprint.setdefault(pid, {}).pop(slot, None) + save_state(pid, state) + return ApiResponse(result=_ui_state_dict(state, printer_id=pid)) -@app.post("/api/ui/spool/set_remaining", response_model=ApiResponse) -def api_ui_spool_set_remaining(req: UiSpoolSetRemainingRequest) -> ApiResponse: - """Übernehmen: set measured remaining weight as new reference (local only). - Does NOT reset epoch and does not delete history. Remaining is computed as: - remaining = ref_remaining - (consumed_epoch - ref_consumed) - """ - state = load_state() +# --- Spoolman integration endpoints --- + +@app.get("/api/ui/spoolman/spools") +def api_ui_spoolman_spools(slot: str = "1A", printer_id: Optional[str] = None): + """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") + + pid = _resolve_printer_id(printer_id, allow_unknown=printer_id is None) + state = load_state(pid) + s = state.slots.get(slot) + cfs_slot = state.cfs_slots.get(slot) if isinstance(state.cfs_slots, dict) else None + has_cfs_snapshot = isinstance(state.cfs_slots, dict) and bool(state.cfs_slots) + slot_present = True + if isinstance(cfs_slot, dict): + slot_present = bool(cfs_slot.get("present", True)) + elif has_cfs_snapshot: + slot_present = False + slot_material = (getattr(s, "material", "") or "").upper() if (s and slot_present) else "" + slot_vendor = (getattr(s, "manufacturer", "") or "").strip() if (s and slot_present) else "" + slot_color = (getattr(s, "color_hex", "") or "").lower() if (s and slot_present) else "" + if isinstance(cfs_slot, dict) and slot_present: + cfs_material = str(cfs_slot.get("material") or "").strip().upper() + cfs_vendor = str(cfs_slot.get("manufacturer") or cfs_slot.get("vendor") or "").strip() + if cfs_material: + slot_material = cfs_material + if cfs_vendor: + slot_vendor = cfs_vendor + + 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, + "printer_id": pid, + "preferred_material": slot_material, + "preferred_vendor": slot_vendor, + } + + +@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") + + pid = _resolve_printer_id(req.printer_id, allow_unknown=req.printer_id is None) + state = load_state(pid) slot = req.slot if slot not in state.slots: raise HTTPException(status_code=404, detail="Unknown slot") - rem_g = float(req.remaining_g) - s = state.slots[slot] - consumed_now = _slot_consumed_g_epoch(state, slot) - s.spool_ref_remaining_g = rem_g - s.spool_ref_consumed_g = float(round(consumed_now, 4)) - s.spool_ref_set_at = time.time() - # legacy - s.remaining_g = rem_g - state.slots[slot] = s - save_state(state) - return ApiResponse(result=_ui_state_dict(state)) + try: + sp = _spoolman_get_spool(base, req.spoolman_id) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Spoolman unreachable: {e}") + filament = sp.get("filament") or {} -@app.post("/api/ui/set_color", response_model=ApiResponse) -def api_ui_set_color(req: UiSetColorRequest) -> ApiResponse: - state = load_state() - if req.slot not in state.slots: + 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 + + state.slots[slot] = s + save_state(pid, state) + + # Write the slot's CFS RFID to the Spoolman spool's extra field for future auto-linking. + # Only do this when the slot is state=2 (physical RFID chip detected). state=1 (manual) + # slots may carry a non-empty rfid field in the WS data (residual/bleed from adjacent slot) + # that must not be written, otherwise two different spools end up with the same cfs_rfid. + cfs_slot_data = state.cfs_slots.get(slot) or {} + rfid = cfs_slot_data.get("rfid", "") + if rfid and cfs_slot_data.get("state") == 2: + _spoolman_set_extra(req.spoolman_id, "cfs_rfid", rfid) + _ws_last_rfid.setdefault(pid, {})[slot] = rfid # mark as seen so auto-link doesn't re-trigger this cycle + + return ApiResponse(result=_ui_state_dict(state, printer_id=pid)) + + +@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.""" + pid = _resolve_printer_id(req.printer_id, allow_unknown=req.printer_id is None) + state = load_state(pid) + slot = req.slot + if slot not in state.slots: raise HTTPException(status_code=404, detail="Unknown slot") - state.slots[req.slot].color_hex = req.color - save_state(state) - return ApiResponse(result=_ui_state_dict(state)) + state.slots[slot].spoolman_id = None + save_state(pid, state) + return ApiResponse(result=_ui_state_dict(state, printer_id=pid)) -@app.post("/api/spool/reset", response_model=AppState) -def api_spool_reset(req: SpoolResetRequest): - state = load_state() - if req.slot not in state.slots: - raise HTTPException(status_code=404, detail="Unknown slot") - state.slots[req.slot].remaining_g = float(req.remaining_g) - save_state(state) - return state +@app.post("/api/ui/jobs/reallocate_spool", response_model=ApiResponse) +def api_ui_jobs_reallocate_spool(req: JobReallocateSpoolRequest) -> ApiResponse: + """Relink a completed job spool usage entry to another Spoolman spool.""" + base = _spoolman_base_url() + if not base: + raise HTTPException(status_code=400, detail="Spoolman URL not configured") + + pid = _resolve_printer_id(req.printer_id, allow_unknown=req.printer_id is None) + req_slot = str(req.slot or "").strip() + if not req_slot: + raise HTTPException(status_code=400, detail="Missing job slot") + req_ended_at = float(req.ended_at) + _jobdb_bootstrap_if_needed() + with _jobdb_connect() as conn: + target = conn.execute( + """ + SELECT j.id AS job_id, s.id AS spool_row_id, s.spoolman_id, s.grams + FROM jobs AS j + JOIN job_spools AS s ON s.job_id = j.id + WHERE j.printer_id = ? AND ABS(j.ended_at - ?) <= 1.0 AND s.slot = ? + ORDER BY j.ended_at DESC, j.id DESC, s.id DESC + LIMIT 1 + """, + (pid, req_ended_at, req_slot), + ).fetchone() + + if target is None: + raise HTTPException(status_code=404, detail="Job spool entry not found") -@app.post("/api/spool/apply_usage", response_model=AppState) -def api_spool_apply_usage(req: SpoolApplyUsageRequest): - state = load_state() - if req.slot not in state.slots: - raise HTTPException(status_code=404, detail="Unknown slot") + try: + new_spool = _spoolman_get_spool(base, req.spoolman_id) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Spoolman unreachable: {e}") + + grams = max(0.0, float(target["grams"] or 0.0)) + old_spool_id = _spoolman_id_or_none(target["spoolman_id"]) + new_spool_id = int(req.spoolman_id) + spool_row_id = int(target["spool_row_id"]) + job_row_id = int(target["job_id"]) + + # Move historical usage between Spoolman spools: + # - Remove job usage from the new linked spool + # - Add that usage back to the old linked spool (if there was one) + if grams > 0 and old_spool_id != new_spool_id: + old_remaining = None + old_target = None + if old_spool_id: + try: + old_spool = _spoolman_get_spool(base, old_spool_id) + old_remaining = _spoolman_remaining_weight(old_spool) + old_target = old_remaining + grams + except Exception as e: + raise HTTPException(status_code=502, detail=f"Failed to read previous spool #{old_spool_id}: {e}") + new_remaining = _spoolman_remaining_weight(new_spool) + new_target = max(0.0, new_remaining - grams) + try: + _spoolman_set_remaining_weight(base, new_spool_id, new_target) + if old_spool_id and old_target is not None: + _spoolman_set_remaining_weight(base, old_spool_id, old_target) + except Exception as e: + # Best-effort rollback so we do not leave usage in a half-moved state. + try: + _spoolman_set_remaining_weight(base, new_spool_id, new_remaining) + except Exception: + pass + raise HTTPException(status_code=502, detail=f"Failed to move spool usage: {e}") + + filament = new_spool.get("filament") or {} + mat = str(filament.get("material") or "").upper() if filament.get("material") is not None else "" + name = str(filament.get("name") or "") if filament.get("name") is not None else "" + manufacturer = str((filament.get("vendor") or {}).get("name") or "") if filament.get("vendor") is not None else "" + color_hex = _normalize_color_hex(str(filament.get("color_hex") or "")) + + with _jobdb_connect() as conn: + conn.execute( + """ + UPDATE job_spools + SET spoolman_id=?, material=?, name=?, manufacturer=?, color_hex=?, needs_link=0 + WHERE id=? + """, + (new_spool_id, mat, name, manufacturer, color_hex, spool_row_id), + ) + missing_count = int( + conn.execute( + """ + SELECT COUNT(*) AS c + FROM job_spools + WHERE job_id=? AND grams > 0 AND (spoolman_id IS NULL OR spoolman_id <= 0) + """, + (job_row_id,), + ).fetchone()["c"] or 0 + ) + conn.execute( + "UPDATE jobs SET needs_link=? WHERE id=?", + (1 if missing_count > 0 else 0, job_row_id), + ) + conn.commit() - current = state.slots[req.slot].remaining_g - if current is None: - raise HTTPException(status_code=409, detail="remaining_g is not set for this slot") + state = load_state(pid) + return ApiResponse(result=_ui_state_dict(state, printer_id=pid)) - new_val = max(0.0, float(current) - float(req.used_g)) - state.slots[req.slot].remaining_g = new_val - save_state(state) - return state +@app.get("/api/ui/spoolman/spool_detail") +def api_ui_spoolman_spool_detail(slot: str = "1A", printer_id: Optional[str] = None): + """Proxy Spoolman spool status for a given CFS slot. -@app.post("/api/job/set", response_model=AppState) -def api_job_set(req: JobSetRequest): - state = load_state() - state.current_job = req.name - state.current_job_filament_mm = 0 - state.current_job_filament_g = 0.0 - state.last_accounted_job_mm = 0 - state.last_accounted_slot = state.active_slot - save_state(state) - return state + Returns {"linked": bool, "slot": str, "spool": dict|null, "error": str|null}. + Never raises HTTP 502 — Spoolman unavailability is returned as a structured error + so the frontend can degrade gracefully. + """ + pid = _resolve_printer_id(printer_id, allow_unknown=printer_id is None) + state = load_state(pid) + slot_obj = state.slots.get(slot) + if slot_obj is None: + raise HTTPException(status_code=404, detail="Unknown slot") + spool_id = getattr(slot_obj, "spoolman_id", None) + if not spool_id: + return {"linked": False, "slot": slot, "spool": None, "error": None, "printer_id": pid} -@app.post("/api/ui/job/set", response_model=ApiResponse) -def api_ui_job_set(req: JobSetRequest) -> ApiResponse: - state = api_job_set(req) - return ApiResponse(result=_ui_state_dict(state)) + base = _spoolman_base_url() + if not base: + return {"linked": True, "slot": slot, "spool": None, "error": "not_configured", "printer_id": pid} + try: + sp = _spoolman_get_spool(base, spool_id) + return {"linked": True, "slot": slot, "spool": sp, "error": None, "printer_id": pid} + except Exception as e: + return {"linked": True, "slot": slot, "spool": None, "error": "unreachable", "printer_id": pid} -@app.post("/api/job/update", response_model=AppState) -def api_job_update(req: JobUpdateRequest): - state = load_state() - _apply_job_usage(state, state.current_job or "", int(req.used_mm), slot_override=req.slot) - save_state(state) - return state +@app.post("/api/ui/set_color", response_model=ApiResponse) +def api_ui_set_color(req: UiSetColorRequest) -> ApiResponse: + pid = _resolve_printer_id(req.printer_id, allow_unknown=req.printer_id is None) + state = load_state(pid) + if req.slot not in state.slots: + raise HTTPException(status_code=404, detail="Unknown slot") + state.slots[req.slot].color_hex = req.color + save_state(pid, state) + return ApiResponse(result=_ui_state_dict(state, printer_id=pid)) -@app.post("/api/ui/job/update", response_model=ApiResponse) -def api_ui_job_update(req: JobUpdateRequest) -> ApiResponse: - state = api_job_update(req) - return ApiResponse(result=_ui_state_dict(state)) @app.post("/api/feed") @@ -1434,14 +2948,19 @@ 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" + "Set printer_urls (or printers) in data/config.json to your printer IPs to enable live CFS slot sync via WebSocket.\n" + "Link a Spoolman spool to a slot to track filament consumption automatically." + ) + else: + text = ( + "Klick einen Slot, um ihn aktiv zu setzen.\n" + "Trage printer_urls (oder printers) in data/config.json mit den IPs deiner Drucker ein, um die CFS-Slots per WebSocket zu synchronisieren.\n" + "Verknüpfe einen Spoolman-Spool mit einem Slot, um den Filamentverbrauch automatisch zu verfolgen." + ) return ApiResponse(result={"text": text}) @@ -1455,31 +2974,37 @@ def api_health(): def default_state() -> AppState: """Safe defaults if state.json is missing/broken. - Must always include all 4x4 CFS slots so the UI never crashes, even if the - state file is corrupted. + Must always include all 4x4 CFS slots and the direct printer spool input so + the UI never crashes, even if the state file is corrupted. """ slots: Dict[str, SlotState] = {} for sid in DEFAULT_SLOTS: - slots[sid] = SlotState(slot=sid, material="OTHER", color_hex="#00aaff", remaining_g=0.0) + slots[sid] = SlotState(slot=sid, material="OTHER", color_hex="#00aaff") + slots[PRINTER_SPOOL_SLOT] = SlotState(slot=PRINTER_SPOOL_SLOT, material="OTHER", color_hex="#00aaff") # Sensible demo defaults for Box 2 (matches the UI screenshot vibe) slots["2A"].material = "ABS" slots["2A"].color_hex = "#4b0082" # indigo-ish - slots["2A"].remaining_g = 1000.0 return AppState( active_slot="2A", auto_mode=False, updated_at=_now(), slots=slots, # type: ignore[arg-type] - current_job="", - current_job_filament_mm=0, - current_job_filament_g=0.0, printer_connected=False, printer_last_error="", cfs_connected=False, cfs_last_update=0.0, cfs_active_slot=None, cfs_slots={}, - cfs_raw={}, + cfs_env_history={}, ) + + +def default_multi_state() -> MultiAppState: + printers: Dict[str, AppState] = {} + for p in load_config().get("printers") or []: + pid = str((p or {}).get("id") or "") + if pid: + printers[pid] = default_state() + return MultiAppState(printers=printers, updated_at=_now()) diff --git a/models/schemas.py b/models/schemas.py index 3634be9..03fbbff 100644 --- a/models/schemas.py +++ b/models/schemas.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Dict, Literal, Optional, Any +from typing import Dict, Literal, Optional, Any, Union, List import time from datetime import datetime, timezone from pydantic import BaseModel, Field, field_validator @@ -11,39 +11,21 @@ "3A", "3B", "3C", "3D", "4A", "4B", "4C", "4D", ] +PrinterSpoolSlotId = Literal["SP"] +PrinterInputId = Union[SlotId, PrinterSpoolSlotId] MaterialType = Literal["PLA", "PETG", "ABS", "ASA", "TPU", "PA", "PC", "OTHER"] class SlotState(BaseModel): - slot: SlotId + slot: PrinterInputId material: MaterialType = "PLA" color_hex: str = Field(default="#00aaff", pattern=r"^#[0-9a-fA-F]{6}$") name: str = "" manufacturer: str = "" - # Optional spool bookkeeping (purely local): - # We store a *reference point* and compute remaining based on consumption - # since that reference. - # - # - spool_ref_remaining_g: measured remaining weight at reference time - # - spool_ref_consumed_g: total consumed for this slot at reference time - # - spool_ref_set_at: unix timestamp when reference was set - # - spool_epoch: increments on roll-change; UI shows only current epoch - spool_ref_remaining_g: Optional[float] = None - spool_ref_consumed_g: Optional[float] = None - spool_ref_set_at: Optional[float] = None + # spool_epoch: increments on roll-change; used for auto-unlink detection spool_epoch: int = 0 - - # Running total of consumed grams for the *current* spool epoch. - # This is used for remaining-weight calculations so that UI history trimming - # ("letzte 4") never changes accounting. - spool_epoch_consumed_g_total: float = 0.0 - - # Legacy fields from older versions (kept for backward compatibility). - # They are no longer used for calculations. - spool_start_g: Optional[float] = None - remaining_g: Optional[float] = None - notes: str = "" + spoolman_id: Optional[int] = None @field_validator("material", mode="before") @classmethod @@ -65,59 +47,48 @@ def normalize_material(cls, v: Any): return "OTHER" +class SlotStats(BaseModel): + total_meters: float = 0.0 + total_kg: float = 0.0 + last_used_at: Optional[float] = None # Unix timestamp + + +class CfsEnvSample(BaseModel): + ts: float + temperature_c: Optional[float] = None + humidity_pct: Optional[float] = None + + class AppState(BaseModel): - active_slot: SlotId = "2A" + active_slot: Optional[str] = None # legacy; frontend uses cfs_active_slot auto_mode: bool = False - slots: Dict[SlotId, SlotState] + slots: Dict[PrinterInputId, SlotState] updated_at: float = Field(default_factory=lambda: time.time()) - # Optional informational fields (UI only) - current_job: str = "" - current_job_filament_mm: int = 0 - current_job_filament_g: float = 0.0 - - # printer connection info (Moonraker) + # printer connection info printer_connected: bool = False printer_last_error: str = "" # CFS / AMS info (read-only from printer, optional) cfs_connected: bool = False cfs_last_update: float = 0.0 - cfs_active_slot: Optional[SlotId] = None + cfs_active_slot: Optional[PrinterInputId] = None cfs_slots: Dict[str, Any] = Field(default_factory=dict) - cfs_raw: Dict[str, Any] = Field(default_factory=dict) - - # Bookkeeping for clean spool deduction (persisted) - last_accounted_job_mm: int = 0 - last_accounted_slot: Optional[SlotId] = None - - # --- Read-only history / usage tracking (persisted) --- - # Per-slot print history (newest first). Each entry is a dict with: - # ts: unix timestamp (float) - # job: gcode filename - # used_mm: int - # used_g: float - slot_history: Dict[str, Any] = Field(default_factory=dict) - - # Current job tracking to attribute filament to slots during a print. - job_track_name: str = "" - job_track_started_at: float = 0.0 - job_track_last_mm: int = 0 - job_track_slot_mm: Dict[str, int] = Field(default_factory=dict) - job_track_slot_g: Dict[str, float] = Field(default_factory=dict) - job_track_last_state: str = "" - - # --- Moonraker global history (read-only, best effort) --- - # Snapshot of Moonraker's /server/history/list. Moonraker history does not - # reliably provide per-slot attribution on Creality CFS, so we display this - # separately from the per-slot tracker. - moonraker_history: Any = Field(default_factory=list) - - # --- Manual attribution for Moonraker history (local only) --- - # Keyed by a stable job key (e.g. ":") with value: - # {"job": str, "ts": float, "alloc_g": {"2A": 12.3, ...}} - # This never talks back to the printer; it's only used to build per-slot history. - moonraker_allocations: Dict[str, Any] = Field(default_factory=dict) + + # Per-slot cumulative usedMaterialLength (m) from last WS snapshot. + # Used to compute Spoolman usage deltas between updates. + ws_slot_length_m: Dict[str, float] = Field(default_factory=dict) + + # Lifetime wear stats per slot (cumulative meters, kg, last usage) + cfs_stats: Dict[str, SlotStats] = Field(default_factory=dict) + # Per-box environmental samples (timestamped) for temperature/humidity charts + cfs_env_history: Dict[str, List[CfsEnvSample]] = Field(default_factory=dict) + # Recent print jobs (most recent last), max 10 entries + job_history: List[Dict[str, Any]] = Field(default_factory=list) + + # Printer identity from WS status messages + printer_name: str = "" + printer_firmware: str = "" @field_validator("updated_at", mode="before") @classmethod @@ -143,33 +114,23 @@ def normalize_updated_at(cls, v: Any): class UpdateSlotRequest(BaseModel): + printer_id: Optional[str] = None material: Optional[MaterialType] = None color_hex: Optional[str] = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$") name: Optional[str] = None manufacturer: Optional[str] = None - spool_start_g: Optional[float] = None - remaining_g: Optional[float] = None - notes: Optional[str] = None class SelectSlotRequest(BaseModel): - slot: SlotId + printer_id: Optional[str] = None + slot: PrinterInputId class SetAutoRequest(BaseModel): + printer_id: Optional[str] = None enabled: bool -class SpoolResetRequest(BaseModel): - slot: SlotId - remaining_g: float - - -class SpoolApplyUsageRequest(BaseModel): - slot: SlotId - used_g: float - - class FeedRequest(BaseModel): mm: float = Field(gt=0, le=200) @@ -178,27 +139,6 @@ class RetractRequest(BaseModel): mm: float = Field(gt=0, le=200) -class JobSetRequest(BaseModel): - name: str - - -class JobUpdateRequest(BaseModel): - used_mm: int = Field(ge=0) - slot: Optional[SlotId] = None - - -class MoonrakerAllocateRequest(BaseModel): - """Assign a Moonraker history job (or its per-color parts) to CFS slots. - - This is purely local bookkeeping (no POST to printer). - """ - - job_key: str - job: str - ts: float - alloc_g: Dict[SlotId, float] - - # --- UI compatibility (the static UI talks to /api/ui/* and expects {"result": ...}) --- @@ -207,31 +147,44 @@ class ApiResponse(BaseModel): class UiSetColorRequest(BaseModel): - slot: SlotId + printer_id: Optional[str] = None + slot: PrinterInputId color: str = Field(pattern=r"^#[0-9a-fA-F]{6}$") class UiSlotUpdateRequest(BaseModel): - slot: SlotId + printer_id: Optional[str] = None + slot: PrinterInputId material: Optional[MaterialType] = None color: Optional[str] = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$") name: Optional[str] = None vendor: Optional[str] = None - spool_start_g: Optional[float] = None - remaining_g: Optional[float] = None - notes: Optional[str] = None class UiSpoolSetStartRequest(BaseModel): - slot: SlotId - start_g: float = Field(gt=0) + printer_id: Optional[str] = None + slot: PrinterInputId + start_g: Optional[float] = None # accepted for backward compat, not stored locally + + +class SpoolmanLinkRequest(BaseModel): + printer_id: Optional[str] = None + slot: PrinterInputId + spoolman_id: int = Field(gt=0) -class UiSpoolSetRemainingRequest(BaseModel): - slot: SlotId - remaining_g: float = Field(ge=0) +class SpoolmanUnlinkRequest(BaseModel): + printer_id: Optional[str] = None + slot: PrinterInputId -class UiSlotResetRequest(BaseModel): - slot: SlotId - remaining_g: float +class JobReallocateSpoolRequest(BaseModel): + printer_id: Optional[str] = None + ended_at: float + slot: str = Field(min_length=1) + spoolman_id: int = Field(gt=0) + + +class MultiAppState(BaseModel): + printers: Dict[str, AppState] + updated_at: float = Field(default_factory=lambda: time.time()) diff --git a/requirements.txt b/requirements.txt index efeada9..3337093 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ fastapi==0.115.0 uvicorn[standard]==0.30.6 pydantic==2.8.2 +websockets>=12.0 +paramiko>=3.0 diff --git a/static/app.js b/static/app.js index 64cd45f..ca05eb7 100644 --- a/static/app.js +++ b/static/app.js @@ -1,6 +1,12 @@ /* Minimal read-only UI for Creality K2 Plus CFS via Moonraker */ const $ = (id) => document.getElementById(id); +const PRINTER_SPOOL_SLOT = "SP"; + +function slotTitle(slotId) { + if (slotId === PRINTER_SPOOL_SLOT) return "Printer Spool Input"; + return `Box ${slotId[0]} · Slot ${slotId[1]}`; +} function fmtTs(ts) { if (!ts) return "—"; @@ -12,13 +18,31 @@ function fmtTs(ts) { } } +function fmtDuration(startTs, endTs) { + const start = Number(startTs || 0); + const end = Number(endTs || 0); + if (!(start > 0) || !(end >= start)) return "—"; + let secs = Math.round(end - start); + const days = Math.floor(secs / 86400); + secs -= days * 86400; + const hours = Math.floor(secs / 3600); + secs -= hours * 3600; + const mins = Math.floor(secs / 60); + secs -= mins * 60; + + if (days > 0) return `${days}d ${hours}h ${mins}m`; + if (hours > 0) return `${hours}h ${mins}m`; + if (mins > 0) return `${mins}m ${secs}s`; + return `${secs}s`; +} + function badge(el, text, cls) { el.classList.remove("ok", "bad", "warn"); if (cls) el.classList.add(cls); el.textContent = text; } -function slotEl(slotId, label, meta, isActive) { +function slotEl(slotId, label, meta, isActive, printerId) { const wrap = document.createElement("div"); wrap.className = "slot" + (isActive ? " active" : ""); wrap.dataset.slotid = slotId; @@ -41,48 +65,57 @@ 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(" · ") : "—"; + if (meta.present === false) { + sub.textContent = "empty"; + txt.appendChild(sub); + left.appendChild(txt); + } else { + // 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); - // Optional spool info (local, derived) - // We show Rest BIG. (verbrauchte/used is available in the history; keeping tiles clean.) - const rem = (meta.spool_remaining_g != null ? meta.spool_remaining_g : meta.remaining_g); - if (rem != null) { - const row = document.createElement('div'); - row.className = 'spoolRow'; - - const rest = document.createElement('div'); - rest.className = 'spoolRest'; - rest.textContent = fmtG(rem); - row.appendChild(rest); - - // warning styles based on remaining grams - const r = Number(rem); - if (Number.isFinite(r)) { - if (r <= 50) wrap.classList.add('spoolCrit'); - else if (r <= 150) wrap.classList.add('spoolLow'); - } - - txt.appendChild(row); + // 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"); 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 ? 'empty' : (isActive ? 'active' : 'ready'); right.appendChild(tag); + if (meta.percent != null) { + const pct = document.createElement("div"); + pct.className = "spoolPct"; + pct.textContent = meta.percent + "%"; + right.appendChild(pct); + } + wrap.appendChild(left); wrap.appendChild(right); wrap.addEventListener("click", (ev) => { ev.preventDefault(); - openSpoolModal(slotId, meta); + openSpoolModal(slotId, meta, printerId); }); return wrap; } @@ -108,87 +141,214 @@ function fmtUsedFromMm(mm) { return m.toFixed(2) + " m"; } -function buildSlotIds(connectedBoxes) { - const slotIds = []; - for (const b of connectedBoxes) { - for (const l of ["A", "B", "C", "D"]) slotIds.push(`${b}${l}`); + +async function postJson(url, payload) { + const r = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!r.ok) { + const txt = await r.text().catch(() => ""); + throw new Error(txt || `HTTP ${r.status}`); } - return slotIds; + return r.json(); +} + +function normalizeHexColor(raw) { + const v = String(raw || "").trim().toLowerCase(); + if (!v) return ""; + const col = v.startsWith("#") ? v : "#" + v; + return /^#[0-9a-f]{6}$/.test(col) ? col : ""; } -function jobKeyFromMoon(e) { - const base = (e.job_id || e.job || "").toString(); - const ts = Math.floor(Number(e.ts_end || 0) || 0); - return `${base}:${ts}`; +function recentJobSlotLabel(slotId) { + const sid = String(slotId || "").toUpperCase(); + if (sid === PRINTER_SPOOL_SLOT) return "Spool"; + if (sid === "UNKNOWN" || sid === "UNASSIGNED" || sid === "?") return "Unassigned"; + if (/^[1-4][A-D]$/.test(sid)) return `CFS Box ${sid[0]} · ${sid}`; + return sid || "—"; } -// --- UI state preservation across auto-refresh --- -// The page re-renders periodically. Without preserving state,
elements -// collapse while the user is interacting (e.g. assigning slots). -const uiState = { - moonOpenKeys: new Set(), - slotOpenKeys: new Set(), - moonSelectValues: {}, -}; +function spoolmanMaterialNorm(v) { + return String(v || '').trim().toUpperCase(); +} -function captureUiState() { - uiState.moonOpenKeys = new Set( - Array.from(document.querySelectorAll('#moonHistory details.moonEntry[open]')) - .map((d) => d.dataset.key) - .filter(Boolean) - ); - uiState.slotOpenKeys = new Set( - Array.from(document.querySelectorAll('#slotHistory details.histEntry[open]')) - .map((d) => d.dataset.key) - .filter(Boolean) - ); - uiState.moonSelectValues = {}; - for (const sel of document.querySelectorAll('#moonHistory select.assignSel')) { - const k = sel.dataset.selkey; - if (k) uiState.moonSelectValues[k] = sel.value; +function spoolmanVendorNorm(v) { + return String(v || '').trim().toLowerCase(); +} + +function spoolmanUniqueSorted(values) { + const seen = new Set(); + const out = []; + for (const raw of values) { + const v = String(raw || '').trim(); + if (!v) continue; + const k = v.toLowerCase(); + if (seen.has(k)) continue; + seen.add(k); + out.push(v); } + out.sort((a, b) => a.localeCompare(b)); + return out; } -function restoreUiState() { - for (const d of document.querySelectorAll('#moonHistory details.moonEntry')) { - const k = d.dataset.key; - if (k && uiState.moonOpenKeys && uiState.moonOpenKeys.has(k)) d.open = true; +function spoolmanFilterSpools(spools, materialFilter, vendorFilter) { + const matNeed = spoolmanMaterialNorm(materialFilter); + const venNeed = spoolmanVendorNorm(vendorFilter); + return (Array.isArray(spools) ? spools : []).filter((sp) => { + const mat = spoolmanMaterialNorm(sp && sp.material); + const ven = spoolmanVendorNorm(sp && sp.vendor); + if (matNeed && mat !== matNeed) return false; + if (venNeed && ven !== venNeed) return false; + return true; + }); +} + +function renderSpoolmanList(listEl, spools, selectedId = null, emptyText = 'No spools found') { + if (!listEl) return; + listEl.innerHTML = ''; + if (!spools.length) { + const o = document.createElement('div'); + o.className = 'spoolmanListItem muted'; + o.textContent = emptyText; + listEl.appendChild(o); + return; } - for (const d of document.querySelectorAll('#slotHistory details.histEntry')) { - const k = d.dataset.key; - if (k && uiState.slotOpenKeys && uiState.slotOpenKeys.has(k)) d.open = true; + for (const sp of spools) { + const item = document.createElement('div'); + item.className = 'spoolmanListItem'; + item.dataset.id = String(sp.id); + + const swatch = document.createElement('span'); + swatch.className = 'spoolmanListSwatch'; + const col = sp.color_hex ? (sp.color_hex.startsWith('#') ? sp.color_hex : '#' + sp.color_hex) : null; + if (col) swatch.style.background = col; + + const label = document.createElement('span'); + const remaining = sp.remaining_weight != null ? fmtG(sp.remaining_weight) : '?'; + label.textContent = `#${sp.id} ${sp.vendor || ''} ${sp.filament_name || ''} · ${sp.material || ''} · ${remaining}`; + + item.appendChild(swatch); + item.appendChild(label); + item.addEventListener('click', () => { + for (const el of listEl.querySelectorAll('.spoolmanListItem')) el.classList.remove('selected'); + item.classList.add('selected'); + }); + listEl.appendChild(item); } - for (const sel of document.querySelectorAll('#moonHistory select.assignSel')) { - const k = sel.dataset.selkey; - if (k && uiState.moonSelectValues && Object.prototype.hasOwnProperty.call(uiState.moonSelectValues, k)) { - sel.value = uiState.moonSelectValues[k]; - } + const pickId = selectedId != null ? Number(selectedId) : Number(spools[0].id || 0); + let selected = null; + if (pickId > 0) { + selected = listEl.querySelector(`.spoolmanListItem[data-id="${pickId}"]`); + if (selected) selected.classList.add('selected'); + } + if (!selected) { + const first = listEl.querySelector('.spoolmanListItem'); + if (first) first.classList.add('selected'); } } -async function postJson(url, payload) { - const r = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!r.ok) { - const t = await r.text().catch(() => ""); - throw new Error(t || `HTTP ${r.status}`); +function renderSpoolmanPicker({ + listEl, + filtersEl, + spools, + selectedId = null, + preferredMaterial = '', + preferredVendor = '', + onVisibleCount = null, +}) { + if (!listEl) return; + const all = Array.isArray(spools) ? spools : []; + + const materials = spoolmanUniqueSorted(all.map(sp => spoolmanMaterialNorm(sp && sp.material))); + const vendors = spoolmanUniqueSorted(all.map(sp => String((sp && sp.vendor) || '').trim())); + + let matValue = ''; + let venValue = ''; + const prefMat = spoolmanMaterialNorm(preferredMaterial); + if (prefMat && prefMat !== 'OTHER' && materials.includes(prefMat)) matValue = prefMat; + const prefVenKey = spoolmanVendorNorm(preferredVendor); + if (prefVenKey) { + const found = vendors.find(v => spoolmanVendorNorm(v) === prefVenKey); + if (found) venValue = found; } - return r.json(); + + const repaint = () => { + const filtered = spoolmanFilterSpools(all, matValue, venValue); + const emptyText = all.length ? 'No matching spools' : 'No spools found'; + renderSpoolmanList(listEl, filtered, selectedId, emptyText); + if (typeof onVisibleCount === 'function') onVisibleCount(filtered.length); + }; + + if (filtersEl) { + filtersEl.innerHTML = ''; + + const matSel = document.createElement('select'); + matSel.className = 'spoolmanFilterSel'; + const matAny = document.createElement('option'); + matAny.value = ''; + matAny.textContent = 'All materials'; + matSel.appendChild(matAny); + for (const mat of materials) { + const o = document.createElement('option'); + o.value = mat; + o.textContent = mat; + matSel.appendChild(o); + } + matSel.value = matValue; + matSel.addEventListener('change', () => { + matValue = matSel.value || ''; + repaint(); + }); + + const venSel = document.createElement('select'); + venSel.className = 'spoolmanFilterSel'; + const venAny = document.createElement('option'); + venAny.value = ''; + venAny.textContent = 'All brands'; + venSel.appendChild(venAny); + for (const ven of vendors) { + const o = document.createElement('option'); + o.value = ven; + o.textContent = ven; + venSel.appendChild(o); + } + venSel.value = venValue; + venSel.addEventListener('change', () => { + venValue = venSel.value || ''; + repaint(); + }); + + filtersEl.appendChild(matSel); + filtersEl.appendChild(venSel); + } + + repaint(); } +// --- Spoolman integration --- +let spoolmanConfigured = false; + // --- Spool editor modal (local only) --- let spoolModalOpen = false; let spoolPrevPaused = null; let spoolSlotId = null; +let spoolPrinterId = null; +let historyRelinkModalOpen = false; +let historyRelinkPrevPaused = null; +let historyRelinkCtx = null; +let envChartModalOpen = false; +let envChartPrevPaused = null; +let envChartCtx = null; +let envChartRange = '24h'; function closeSpoolModal() { const m = $('spoolModal'); if (m) m.style.display = 'none'; spoolModalOpen = false; spoolSlotId = null; + spoolPrinterId = null; if (spoolPrevPaused !== null) { refreshPaused = spoolPrevPaused; spoolPrevPaused = null; @@ -196,12 +356,116 @@ function closeSpoolModal() { } } -function openSpoolModal(slotId, meta) { +function closeHistoryRelinkModal() { + const m = $('historyRelinkModal'); + if (m) m.style.display = 'none'; + const applyBtn = $('historyRelinkApply'); + if (applyBtn) { + applyBtn.disabled = false; + applyBtn.textContent = 'Relink'; + } + historyRelinkModalOpen = false; + historyRelinkCtx = null; + if (historyRelinkPrevPaused !== null) { + refreshPaused = historyRelinkPrevPaused; + historyRelinkPrevPaused = null; + applyRefreshTimer(); + } +} + +function closeEnvChartModal() { + const m = $('envChartModal'); + if (m) m.style.display = 'none'; + const body = $('envChartBody'); + if (body) body.innerHTML = ''; + envChartCtx = null; + envChartModalOpen = false; + if (envChartPrevPaused !== null) { + refreshPaused = envChartPrevPaused; + envChartPrevPaused = null; + applyRefreshTimer(); + } +} + +async function loadHistoryRelinkDropdown(ctx) { + const list = $('historyRelinkSelect'); + if (!list) return; + const filters = $('historyRelinkFilters'); + if (filters) filters.innerHTML = ''; + list.innerHTML = ''; + const ph = document.createElement('div'); + ph.className = 'spoolmanListItem muted'; + ph.textContent = 'Loading spools...'; + list.appendChild(ph); + + const applyBtn = $('historyRelinkApply'); + if (applyBtn) applyBtn.disabled = true; + + try { + const r = await fetch(`/api/ui/spoolman/spools?slot=${encodeURIComponent(ctx.slot)}&printer_id=${encodeURIComponent(ctx.printerId || '')}`, { cache: 'no-store' }); + if (!r.ok) throw new Error(await r.text()); + const data = await r.json(); + const spools = Array.isArray(data.spools) ? data.spools : []; + renderSpoolmanPicker({ + listEl: list, + filtersEl: filters, + spools, + selectedId: ctx.currentSpoolId || null, + preferredMaterial: ctx.material || data.preferred_material || '', + preferredVendor: ctx.manufacturer || data.preferred_vendor || '', + onVisibleCount: (count) => { + if (applyBtn) applyBtn.disabled = !count; + }, + }); + } catch (e) { + if (filters) filters.innerHTML = ''; + list.innerHTML = ''; + const o = document.createElement('div'); + o.className = 'spoolmanListItem muted'; + o.textContent = `Spoolman error: ${e.message || String(e)}`; + list.appendChild(o); + if (applyBtn) applyBtn.disabled = true; + } +} + +async function openHistoryRelinkModal(ctx) { + const m = $('historyRelinkModal'); + if (!m) return; + historyRelinkModalOpen = true; + historyRelinkCtx = ctx; + + if (historyRelinkPrevPaused === null) historyRelinkPrevPaused = refreshPaused; + refreshPaused = true; + applyRefreshTimer(); + + const title = $('historyRelinkTitle'); + const sub = $('historyRelinkSub'); + const hint = $('historyRelinkHint'); + const applyBtn = $('historyRelinkApply'); + if (title) title.textContent = `${ctx.currentSpoolId ? 'Relink' : 'Link'} Job Spool`; + if (applyBtn) applyBtn.textContent = ctx.currentSpoolId ? 'Relink' : 'Link'; + if (sub) { + const usage = `${Number(ctx.meters || 0).toFixed(2)} m · ${fmtG(Number(ctx.grams || 0))}`; + const linked = ctx.currentSpoolId ? `#${ctx.currentSpoolId}` : 'not linked'; + sub.textContent = `${ctx.printerId} · ${recentJobSlotLabel(ctx.slot)} · ${linked} · ${usage}`; + } + if (hint) { + hint.textContent = ctx.currentSpoolId + ? 'Usage will be moved from the currently linked spool to the selected spool.' + : 'Usage will be applied to the selected spool for this job entry.'; + } + + m.style.display = 'block'; + await loadHistoryRelinkDropdown(ctx); +} + +function openSpoolModal(slotId, meta, printerId) { // Only open if modal exists (older builds) const m = $('spoolModal'); if (!m) return; spoolModalOpen = true; spoolSlotId = slotId; + spoolPrinterId = printerId || null; // Pause auto-refresh while editing so nothing collapses if (spoolPrevPaused === null) spoolPrevPaused = refreshPaused; @@ -210,34 +474,104 @@ function openSpoolModal(slotId, meta) { const title = $('spoolTitle'); const sub = $('spoolSub'); - const st = $('spoolStats'); - if (title) title.textContent = `Box ${slotId[0]} · Slot ${slotId[1]}`; - if (sub) sub.textContent = `${meta.material || '—'} · ${(meta.color || '').toUpperCase() || '—'}`; - - const startEl = $('spoolStart'); - const remEl = $('spoolRemain'); - // Prefill: use computed remaining if available (rounded), otherwise legacy remaining_g - const prefRem = (meta.spool_remaining_g != null ? meta.spool_remaining_g : meta.remaining_g); - if (remEl) remEl.value = (prefRem != null ? String(Math.round(Number(prefRem))) : ''); - // New roll input stays empty by default - if (startEl) startEl.value = ''; - - if (st) { - const remG = (meta.spool_remaining_g != null ? meta.spool_remaining_g : meta.remaining_g); - const usedG = meta.spool_used_g; - const totalG = meta.spool_consumed_g; - if (remG != null && usedG != null) { - st.textContent = `Rest (berechnet): ${fmtG(remG)} · verbraucht seit Übernahme: ${fmtG(usedG)} · Gesamt (Slot): ${fmtG(totalG != null ? totalG : 0)}`; - } else if (remG != null) { - st.textContent = `Rest (aktuell): ${fmtG(remG)} · Tipp: "Istgewicht" eintragen und Übernehmen.`; + if (title) title.textContent = slotTitle(slotId); + if (sub) { + if (meta.present === false) { + sub.textContent = "empty"; } else { - st.textContent = 'Noch kein Referenzwert. Trage "Istgewicht" ein und klicke Übernehmen.'; + sub.textContent = `${meta.material || '—'} · ${(meta.color || '').toUpperCase() || '—'}`; + } + } + + // --- Spoolman section --- + const smSec = $('spoolmanSection'); + if (smSec) { + if (spoolmanConfigured) { + smSec.style.display = ''; + const bdg = $('spoolmanBadge'); + const notLinked = $('spoolmanNotLinked'); + const linked = $('spoolmanLinked'); + const info = $('spoolmanInfo'); + const smId = meta.spoolman_id; + if (smId) { + 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 = 'Loading spool data…'; + // Fetch live remaining from Spoolman + fetch(`/api/ui/spoolman/spool_detail?slot=${encodeURIComponent(slotId)}&printer_id=${encodeURIComponent(printerId || '')}`, { cache: 'no-store' }) + .then(r => r.json()) + .then(data => { + if (data.spool) { + const fil = data.spool.filament || {}; + const vendor = (fil.vendor || {}).name || meta.manufacturer || meta.vendor || ''; + const name = fil.name || meta.name || ''; + const material = (fil.material || '').toUpperCase(); + const remaining = data.spool.remaining_weight != null ? fmtG(data.spool.remaining_weight) : '—'; + info.textContent = [vendor, name, material, remaining].filter(Boolean).join(' · '); + } else { + info.textContent = data.error ? 'Spoolman unreachable' : `Spool #${smId}`; + } + }) + .catch(() => { + info.textContent = 'Spoolman unreachable'; + }); + } + } else { + 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, printerId); + } + } else { + smSec.style.display = 'none'; } } m.style.display = 'block'; } +async function loadSpoolmanDropdown(slotId, printerId) { + const list = $('spoolmanSelect'); + if (!list) return; + const filters = $('spoolmanFilters'); + if (filters) filters.innerHTML = ''; + const linkBtn = $('spoolmanLink'); + if (linkBtn) linkBtn.disabled = true; + list.innerHTML = ''; + const ph = document.createElement('div'); + ph.className = 'spoolmanListItem muted'; + ph.textContent = 'Loading spools…'; + list.appendChild(ph); + + try { + const r = await fetch(`/api/ui/spoolman/spools?slot=${encodeURIComponent(slotId)}&printer_id=${encodeURIComponent(printerId || '')}`, { cache: 'no-store' }); + if (!r.ok) throw new Error(await r.text()); + const data = await r.json(); + const spools = Array.isArray(data.spools) ? data.spools : []; + renderSpoolmanPicker({ + listEl: list, + filtersEl: filters, + spools, + selectedId: null, + preferredMaterial: data.preferred_material || '', + preferredVendor: data.preferred_vendor || '', + onVisibleCount: (count) => { + if (linkBtn) linkBtn.disabled = !count; + }, + }); + } catch (e) { + if (filters) filters.innerHTML = ''; + list.innerHTML = ''; + const o = document.createElement('div'); + o.className = 'spoolmanListItem muted'; + o.textContent = `Spoolman error: ${e.message || String(e)}`; + list.appendChild(o); + if (linkBtn) linkBtn.disabled = true; + } +} + function initSpoolModal() { const m = $('spoolModal'); if (!m) return; @@ -264,528 +598,1168 @@ function initSpoolModal() { }); const saveStart = $('spoolSaveStart'); - const saveRemain = $('spoolSaveRemain'); if (saveStart) { saveStart.onclick = async (ev) => { ev.preventDefault(); ev.stopPropagation(); - if (!spoolSlotId) return; - const v = Number(($('spoolStart') || {}).value || 0); - if (!Number.isFinite(v) || v <= 0) return; - // Rollwechsel: new epoch + new reference - await postJson('/api/ui/spool/set_start', { slot: spoolSlotId, start_g: v }); + if (!spoolSlotId || !spoolPrinterId) return; + // Rollwechsel: new epoch + auto-unlink Spoolman + await postJson('/api/ui/spool/set_start', { printer_id: spoolPrinterId, slot: spoolSlotId }); + closeSpoolModal(); + 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 || !spoolPrinterId) return; + const list = $('spoolmanSelect'); + const selected = list && list.querySelector('.spoolmanListItem.selected'); + const id = selected ? Number(selected.dataset.id) : 0; + if (!id) return; + await postJson('/api/ui/spoolman/link', { printer_id: spoolPrinterId, slot: spoolSlotId, spoolman_id: id }); closeSpoolModal(); await tick(); }; } - if (saveRemain) { - saveRemain.onclick = async (ev) => { + if (smUnlink) { + smUnlink.onclick = async (ev) => { ev.preventDefault(); ev.stopPropagation(); - if (!spoolSlotId) return; - const v = Number(($('spoolRemain') || {}).value || 0); - if (!Number.isFinite(v) || v < 0) return; - // Übernehmen: set measured remaining as reference (no epoch reset) - await postJson('/api/ui/spool/set_remaining', { slot: spoolSlotId, remaining_g: v }); + if (!spoolSlotId || !spoolPrinterId) return; + await postJson('/api/ui/spoolman/unlink', { printer_id: spoolPrinterId, slot: spoolSlotId }); closeSpoolModal(); await tick(); }; } + + if (smRefresh) { + smRefresh.onclick = async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + if (!spoolSlotId || !spoolPrinterId) return; + // Re-fetch spool detail from Spoolman + const info = $('spoolmanInfo'); + try { + if (info) info.textContent = 'Loading spool data…'; + const r = await fetch(`/api/ui/spoolman/spool_detail?slot=${encodeURIComponent(spoolSlotId)}&printer_id=${encodeURIComponent(spoolPrinterId)}`, { cache: 'no-store' }); + const data = await r.json(); + if (data.spool) { + const fil = data.spool.filament || {}; + const vendor = (fil.vendor || {}).name || ''; + const name = fil.name || ''; + const material = (fil.material || '').toUpperCase(); + const remaining = data.spool.remaining_weight != null ? fmtG(data.spool.remaining_weight) : '—'; + if (info) info.textContent = [vendor, name, material, remaining].filter(Boolean).join(' · '); + } else { + if (info) info.textContent = data.error ? 'Spoolman unreachable' : '—'; + } + } catch (e) { + if (info) info.textContent = `Spoolman error: ${e.message || String(e)}`; + } + }; + } } -function renderMoonHistory(state, connectedBoxes) { - const wrap = $("moonHistory"); - if (!wrap) return; - wrap.innerHTML = ""; +function initHistoryRelinkModal() { + const m = $('historyRelinkModal'); + if (!m) return; + const closeBtn = $('historyRelinkClose'); + const back = $('historyRelinkBackdrop'); + if (closeBtn) closeBtn.onclick = (ev) => { + if (ev) { ev.preventDefault(); ev.stopPropagation(); } + closeHistoryRelinkModal(); + }; + if (back) back.onclick = (ev) => { + if (ev) { ev.preventDefault(); ev.stopPropagation(); } + closeHistoryRelinkModal(); + }; - const hist = Array.isArray(state.moonraker_history) ? state.moonraker_history : []; - if (!hist.length) { - const empty = document.createElement("div"); - empty.className = "tag muted"; - empty.textContent = "Keine Moonraker-History Daten"; - wrap.appendChild(empty); - return; + document.addEventListener('keydown', (ev) => { + if (!historyRelinkModalOpen) return; + if (ev.key === 'Escape') { + ev.preventDefault(); + closeHistoryRelinkModal(); + } + }); + + const applyBtn = $('historyRelinkApply'); + if (applyBtn) { + applyBtn.onclick = async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + if (!historyRelinkCtx) return; + const list = $('historyRelinkSelect'); + const selected = list && list.querySelector('.spoolmanListItem.selected'); + const id = selected ? Number(selected.dataset.id) : 0; + if (!id) return; + applyBtn.disabled = true; + const prevText = applyBtn.textContent; + applyBtn.textContent = 'Saving...'; + try { + await postJson('/api/ui/jobs/reallocate_spool', { + printer_id: historyRelinkCtx.printerId, + ended_at: historyRelinkCtx.endedAt, + slot: historyRelinkCtx.slot, + spoolman_id: id, + }); + closeHistoryRelinkModal(); + await tick(); + } catch (e) { + window.alert(`Failed to reallocate spool: ${e.message || String(e)}`); + applyBtn.disabled = false; + applyBtn.textContent = prevText || 'Relink'; + } + }; } +} - const slotIds = buildSlotIds(connectedBoxes); - const allocStore = (state.moonraker_allocations && typeof state.moonraker_allocations === "object") ? state.moonraker_allocations : {}; +function envMetricMeta(metricKey) { + if (metricKey === 'humidity_pct') { + return { title: 'Humidity', unit: '%', lineClass: 'envLineHum', areaClass: 'envAreaHum' }; + } + return { title: 'Temperature', unit: '°C', lineClass: 'envLineTemp', areaClass: 'envAreaTemp' }; +} - for (const e of hist.slice(0, 12)) { - const key = jobKeyFromMoon(e); - // If this job is already assigned locally, it should disappear from the - // Moonraker list (it will show up under "Historie pro Slot"). - if (allocStore[key]) continue; +function fmtEnvValue(v, metricKey) { + const n = Number(v); + if (!Number.isFinite(n)) return '—'; + if (metricKey === 'humidity_pct') return `${Math.round(n)}%`; + return `${n.toFixed(1)}°C`; +} - const det = document.createElement("details"); - det.className = "moonEntry"; - det.dataset.key = key; +function envHistorySeries(history, metricKey) { + const out = []; + for (const item of (Array.isArray(history) ? history : [])) { + if (!item || typeof item !== 'object') continue; + const ts = Number(item.ts || 0); + const v = Number(item[metricKey]); + if (!(ts > 0) || Number.isNaN(v)) continue; + out.push({ ts, value: v }); + } + out.sort((a, b) => a.ts - b.ts); + return out; +} - const sum = document.createElement("summary"); - const row = document.createElement("div"); - row.className = "moonRow"; +function downsampleEnvSeries(series, maxPoints = 360) { + if (series.length <= maxPoints) return series; + const step = Math.ceil(series.length / maxPoints); + const reduced = []; + for (let i = 0; i < series.length; i += step) reduced.push(series[i]); + if (reduced[reduced.length - 1] !== series[series.length - 1]) reduced.push(series[series.length - 1]); + return reduced; +} - const job = document.createElement("div"); - job.className = "moonJob"; - job.textContent = e.job || "(ohne name)"; +function envRangeWindowSecs(rangeKey) { + if (rangeKey === '7d') return 7 * 24 * 3600; + if (rangeKey === '30d') return 30 * 24 * 3600; + return 24 * 3600; +} - const nums = document.createElement("div"); - nums.className = "moonNums"; - const gTotal = (typeof e.filament_used_g_total === "number") ? e.filament_used_g_total : null; - const mm = (typeof e.filament_used_mm === "number") ? e.filament_used_mm : null; - // primary: grams (user relevant). fallback: meters. - nums.textContent = gTotal != null ? fmtG(gTotal) : (mm != null ? fmtUsedFromMm(mm) : "—"); +function envRangeLabel(rangeKey) { + if (rangeKey === '7d') return 'Week'; + if (rangeKey === '30d') return 'Month'; + return '24h'; +} - row.appendChild(job); - row.appendChild(nums); - sum.appendChild(row); - det.appendChild(sum); +function filterEnvSeriesByRange(series, rangeKey) { + if (!series.length) return []; + const lastTs = series[series.length - 1].ts; + const cutoff = lastTs - envRangeWindowSecs(rangeKey); + let anchor = null; + const out = []; + for (const p of series) { + if (p.ts < cutoff) { + anchor = p; + continue; + } + out.push(p); + } + if (anchor && out.length) out.unshift(anchor); + if (!out.length && anchor) out.push(anchor); + return out; +} - const sub = document.createElement("div"); - sub.className = "moonSub"; +function updateEnvChartRangeButtons() { + const wrap = $('envChartRanges'); + if (!wrap) return; + const buttons = wrap.querySelectorAll('.envRangeBtn[data-range]'); + for (const btn of buttons) { + const range = btn.getAttribute('data-range') || ''; + btn.classList.toggle('active', range === envChartRange); + } +} - const when = document.createElement("span"); - when.textContent = "🕒 " + fmtTs(e.ts_end || e.ts_start); - sub.appendChild(when); +function rerenderEnvChart() { + if (!envChartCtx) return; + renderEnvChart(envChartCtx.metricKey, envChartCtx.history, envChartRange); +} - const st = document.createElement("span"); - st.textContent = "📌 " + String(e.status || ""); - sub.appendChild(st); +function renderEnvChart(metricKey, history, rangeKey) { + const body = $('envChartBody'); + const meta = $('envChartMeta'); + if (!body) return; + body.innerHTML = ''; + const mm = envMetricMeta(metricKey); + const allPoints = envHistorySeries(history, metricKey); + const points = downsampleEnvSeries(filterEnvSeriesByRange(allPoints, rangeKey)); + + if (!points.length) { + if (meta) meta.textContent = 'No samples available yet.'; + const empty = document.createElement('div'); + empty.className = 'envChartEmpty'; + empty.textContent = 'No history yet. Wait for live CFS updates to collect samples.'; + body.appendChild(empty); + return; + } - if (e.filament_type) { - const ft = document.createElement("span"); - ft.textContent = "🧵 " + String(e.filament_type); - sub.appendChild(ft); - } + const first = points[0]; + const last = points[points.length - 1]; + if (meta) { + meta.textContent = `${envRangeLabel(rangeKey)} view · Samples: ${points.length} · ${fmtTs(first.ts)} → ${fmtTs(last.ts)} · Latest: ${fmtEnvValue(last.value, metricKey)}`; + } - // --- Slot assignment (local) --- - const existing = null; - - const assign = document.createElement("div"); - assign.className = "assignWrap" + (existing ? " assigned" : ""); - - const assignTitle = document.createElement("div"); - assignTitle.className = "assignTitle"; - assignTitle.textContent = existing ? "Zuordnung (lokal gespeichert)" : "Zu Slot zuordnen (lokal)"; - 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.style.display = existing ? "inline-flex" : "none"; - editBtn.onclick = () => { - assign.classList.toggle("assigned"); - }; - assign.appendChild(editBtn); - - const cols = Array.isArray(e.colors) ? e.colors : []; - const isMulti = Array.isArray(e.filament_used_g) && e.filament_used_g.length > 1; - - const rows = document.createElement("div"); - rows.className = "assignRows"; - - const makeSelect = (pre, selKey) => { - const sel = document.createElement("select"); - sel.className = "assignSel"; - if (selKey) sel.dataset.selkey = selKey; - const opt0 = document.createElement("option"); - opt0.value = ""; - opt0.textContent = "— Slot wählen —"; - sel.appendChild(opt0); - for (const sid of slotIds) { - const o = document.createElement("option"); - o.value = sid; - o.textContent = `Box ${sid[0]} · ${sid}`; - sel.appendChild(o); - } - if (pre) sel.value = pre; - return sel; - }; + const NS = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(NS, 'svg'); + svg.setAttribute('class', 'envChartSvg'); + svg.setAttribute('viewBox', '0 0 820 320'); + svg.setAttribute('preserveAspectRatio', 'none'); + + const pad = { left: 56, right: 16, top: 16, bottom: 32 }; + const w = 820; + const h = 320; + const plotW = w - pad.left - pad.right; + const plotH = h - pad.top - pad.bottom; + const xMin = first.ts; + const xMax = last.ts > xMin ? last.ts : xMin + 1; + + let yMin = Math.min(...points.map(p => p.value)); + let yMax = Math.max(...points.map(p => p.value)); + if (Math.abs(yMax - yMin) < 0.001) { + const bump = metricKey === 'humidity_pct' ? 2 : 1; + yMin -= bump; + yMax += bump; + } else { + const padY = (yMax - yMin) * 0.12; + yMin -= padY; + yMax += padY; + } + const yRange = yMax - yMin; + + const toX = (ts) => pad.left + ((ts - xMin) / (xMax - xMin)) * plotW; + const toY = (v) => pad.top + (1 - ((v - yMin) / yRange)) * plotH; + + for (let i = 0; i <= 4; i++) { + const y = pad.top + (plotH / 4) * i; + const val = yMax - (yRange / 4) * i; + const line = document.createElementNS(NS, 'line'); + line.setAttribute('class', 'envGridLine'); + line.setAttribute('x1', String(pad.left)); + line.setAttribute('x2', String(w - pad.right)); + line.setAttribute('y1', String(y)); + line.setAttribute('y2', String(y)); + svg.appendChild(line); + + const label = document.createElementNS(NS, 'text'); + label.setAttribute('class', 'envAxisText'); + label.setAttribute('x', String(pad.left - 8)); + label.setAttribute('y', String(y + 4)); + label.setAttribute('text-anchor', 'end'); + label.textContent = metricKey === 'humidity_pct' ? `${Math.round(val)}%` : `${val.toFixed(1)}°C`; + svg.appendChild(label); + } - const perColor = []; - if (Array.isArray(e.filament_used_g) && e.filament_used_g.length) { - for (let i = 0; i < e.filament_used_g.length; i++) { - const g = Number(e.filament_used_g[i] || 0); - if (g <= 0) continue; - const c = (cols[i] && typeof cols[i] === "string" && cols[i].startsWith("#")) ? cols[i].toUpperCase() : ("#" + String(i + 1)); - perColor.push({ color: c, g }); - } - } else if (gTotal != null && gTotal > 0) { - perColor.push({ color: "gesamt", g: Number(gTotal) }); - } + const pathPairs = points.map((p) => [toX(p.ts), toY(p.value)]); + const pathPoints = pathPairs.map(([x, y]) => `${x},${y}`).join(' '); + const areaPoints = pathPairs.map(([x, y]) => `${x} ${y}`).join(' '); + const firstX = toX(points[0].ts); + const lastX = toX(last.ts); + const baselineY = pad.top + plotH; + + const area = document.createElementNS(NS, 'path'); + area.setAttribute('class', mm.areaClass); + area.setAttribute('d', `M ${firstX} ${baselineY} L ${areaPoints} L ${lastX} ${baselineY} Z`); + svg.appendChild(area); + + const line = document.createElementNS(NS, 'polyline'); + line.setAttribute('class', mm.lineClass); + line.setAttribute('points', pathPoints); + svg.appendChild(line); + + const dot = document.createElementNS(NS, 'circle'); + dot.setAttribute('class', 'envPoint'); + dot.setAttribute('cx', String(lastX)); + dot.setAttribute('cy', String(toY(last.value))); + dot.setAttribute('r', '4'); + dot.setAttribute('stroke', metricKey === 'humidity_pct' ? '#3fb6ff' : '#ff8a3d'); + svg.appendChild(dot); + + const xStart = document.createElementNS(NS, 'text'); + xStart.setAttribute('class', 'envAxisText'); + xStart.setAttribute('x', String(pad.left)); + xStart.setAttribute('y', String(h - 10)); + xStart.textContent = new Date(first.ts * 1000).toLocaleString(); + svg.appendChild(xStart); + + const xEnd = document.createElementNS(NS, 'text'); + xEnd.setAttribute('class', 'envAxisText'); + xEnd.setAttribute('x', String(w - pad.right)); + xEnd.setAttribute('y', String(h - 10)); + xEnd.setAttribute('text-anchor', 'end'); + xEnd.textContent = new Date(last.ts * 1000).toLocaleString(); + svg.appendChild(xEnd); + + body.appendChild(svg); +} - if (!perColor.length) { - const note = document.createElement("div"); - note.className = "tag muted"; - note.textContent = "Kein Verbrauch in History gefunden"; - assign.appendChild(note); - } else { - // Build UI rows - let idx = 0; - for (const it of perColor) { - const r = document.createElement("div"); - r.className = "assignRow"; - - const pill = document.createElement("span"); - pill.className = "miniPill"; - pill.textContent = `${it.color} · ${fmtG(it.g)}`; - r.appendChild(pill); - - const sel = makeSelect("", `${key}:${idx}`); - r.appendChild(sel); - rows.appendChild(r); - it._sel = sel; - idx += 1; - } +function openEnvChartModal(ctx) { + const m = $('envChartModal'); + if (!m) return; + envChartModalOpen = true; + envChartCtx = ctx; + envChartRange = '24h'; - assign.appendChild(rows); - - const actions = document.createElement("div"); - actions.className = "assignActions"; - const btn = document.createElement("button"); - btn.className = "btn"; - btn.textContent = existing ? "Zuordnung aktualisieren" : "Zuordnen"; - btn.onclick = async () => { - try { - const alloc = {}; - for (const it of perColor) { - const sid = it._sel.value; - if (!sid) continue; - alloc[sid] = (alloc[sid] || 0) + Number(it.g || 0); - } - if (!Object.keys(alloc).length) { - alert("Bitte mindestens einen Slot wählen."); - return; - } - const payload = { job_key: key, job: e.job || "", ts: Number(e.ts_end || e.ts_start || 0), alloc_g: alloc }; - await postJson("/api/ui/moonraker/allocate", payload); - // Force refresh - await tick(); - } catch (err) { - alert("Konnte nicht speichern: " + (err && err.message ? err.message : String(err))); - } - }; - actions.appendChild(btn); + if (envChartPrevPaused === null) envChartPrevPaused = refreshPaused; + refreshPaused = true; + applyRefreshTimer(); - if (existing && typeof existing === "object") { - const info = document.createElement("div"); - info.className = "tag"; - const parts = []; - for (const [sid, g] of Object.entries(existing)) parts.push(`${sid}: ${fmtG(g)}`); - info.textContent = "Aktuell: " + parts.join(" · "); - actions.appendChild(info); - } - assign.appendChild(actions); + const mm = envMetricMeta(ctx.metricKey); + const title = $('envChartTitle'); + const sub = $('envChartSub'); + if (title) title.textContent = `CFS Box ${ctx.boxId} · ${mm.title}`; + if (sub) { + const printer = ctx.printerName || ctx.printerId || 'Printer'; + sub.textContent = `${printer} · ${ctx.printerId || ''}`.replace(/\s·\s$/, ''); + } + + updateEnvChartRangeButtons(); + rerenderEnvChart(); + m.style.display = 'block'; +} + +function initEnvChartModal() { + const m = $('envChartModal'); + if (!m) return; + const closeBtn = $('envChartClose'); + const back = $('envChartBackdrop'); + const rangeWrap = $('envChartRanges'); + if (closeBtn) closeBtn.onclick = (ev) => { + if (ev) { ev.preventDefault(); ev.stopPropagation(); } + closeEnvChartModal(); + }; + if (back) back.onclick = (ev) => { + if (ev) { ev.preventDefault(); ev.stopPropagation(); } + closeEnvChartModal(); + }; + document.addEventListener('keydown', (ev) => { + if (!envChartModalOpen) return; + if (ev.key === 'Escape') { + ev.preventDefault(); + closeEnvChartModal(); } + }); - sub.appendChild(assign); + if (rangeWrap) { + const buttons = rangeWrap.querySelectorAll('.envRangeBtn[data-range]'); + for (const btn of buttons) { + btn.addEventListener('click', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const range = btn.getAttribute('data-range') || '24h'; + if (!['24h', '7d', '30d'].includes(range)) return; + envChartRange = range; + updateEnvChartRangeButtons(); + rerenderEnvChart(); + }); + } + updateEnvChartRangeButtons(); + } +} - det.appendChild(sub); - wrap.appendChild(det); + +function fmtRelative(ts) { + if (!ts) return '—'; + const secs = Math.floor(Date.now() / 1000 - ts); + if (secs < 60) return 'just now'; + if (secs < 3600) return Math.floor(secs / 60) + 'm ago'; + if (secs < 86400) return Math.floor(secs / 3600) + 'h ago'; + if (secs < 86400 * 30) return Math.floor(secs / 86400) + 'd ago'; + return Math.floor(secs / (86400 * 30)) + 'mo ago'; +} + +function inferLiveCfsBoxes(cfsSlots) { + if (!cfsSlots || typeof cfsSlots !== "object") return []; + const out = new Set(); + for (const sid of Object.keys(cfsSlots)) { + if (!/^[1-4][A-D]$/.test(sid)) continue; + const m = cfsSlots[sid] || {}; + const rfid = String(m.rfid ?? "").trim(); + const hasLiveSignal = ( + m.present === true || + Number(m.state ?? 0) > 0 || + Number(m.selected ?? 0) === 1 || + m.percent != null || + (rfid && !["0", "00", "000", "0000", "00000", "000000"].includes(rfid)) + ); + if (hasLiveSignal) out.add(sid[0]); } + return Array.from(out).sort(); } -function renderHistory(state, slots, connectedBoxes) { - const wrap = $("slotHistory"); - if (!wrap) return; - wrap.innerHTML = ""; +function connectedCfsBoxesForState(state) { + const st = state || {}; + const cfsSlots = (st.cfs_slots && typeof st.cfs_slots === "object") ? st.cfs_slots : {}; + const boxesMeta = (cfsSlots._boxes && typeof cfsSlots._boxes === "object") ? cfsSlots._boxes : {}; - const history = state.slot_history || {}; - const active = state.cfs_active_slot || state.active_slot || null; + const byMeta = []; + for (const [k, v] of Object.entries(boxesMeta)) { + const boxNum = Number(k); + if (!(boxNum >= 1 && boxNum <= 4)) continue; + if (v && v.connected === true) byMeta.push(String(boxNum)); + } + if (byMeta.length) return byMeta; + return inferLiveCfsBoxes(cfsSlots); +} - const slotIds = buildSlotIds(connectedBoxes); +function renderCfsStats(state, wrap) { + if (!wrap) return; + wrap.innerHTML = ''; + + const stats = state.cfs_stats || {}; + const cfsSlots = state.cfs_slots || {}; + + // Always show direct spool input status in the status panel, using the + // same distance/grams metrics as CFS slots. + const spoolStats = stats[PRINTER_SPOOL_SLOT] || {}; + const spoolMetersVal = Number(spoolStats.total_meters || 0); + const spoolKgVal = Number(spoolStats.total_kg || 0); + + const spoolDiv = document.createElement('div'); + spoolDiv.className = 'cfsBox'; + const spoolHead = document.createElement('div'); + spoolHead.className = 'cfsBoxHead'; + const spoolHeadLabel = document.createElement('span'); + spoolHeadLabel.textContent = 'Spool'; + const spoolHeadTotals = document.createElement('span'); + spoolHeadTotals.className = 'cfsBoxTotals'; + spoolHeadTotals.textContent = `${spoolMetersVal.toFixed(1)} m · ${fmtG(spoolKgVal * 1000)}`; + spoolHead.appendChild(spoolHeadLabel); + spoolHead.appendChild(spoolHeadTotals); + spoolDiv.appendChild(spoolHead); + + const spoolRow = document.createElement('div'); + spoolRow.className = 'cfsSlotRow'; + const spoolLabel = document.createElement('span'); + spoolLabel.className = 'cfsSlotLabel'; + spoolLabel.textContent = PRINTER_SPOOL_SLOT; + const spoolMeters = document.createElement('span'); + spoolMeters.className = 'cfsSlotMeters'; + spoolMeters.textContent = spoolMetersVal.toFixed(1) + ' m'; + const spoolKg = document.createElement('span'); + spoolKg.className = 'cfsSlotKg'; + spoolKg.textContent = fmtG(spoolKgVal * 1000); + const spoolLast = document.createElement('span'); + spoolLast.className = 'cfsSlotLast'; + spoolLast.textContent = fmtRelative(spoolStats.last_used_at || null); + spoolRow.appendChild(spoolLabel); + spoolRow.appendChild(spoolMeters); + spoolRow.appendChild(spoolKg); + spoolRow.appendChild(spoolLast); + spoolDiv.appendChild(spoolRow); + wrap.appendChild(spoolDiv); + + const boxesMeta = cfsSlots['_boxes'] || {}; + const activeBoxIds = Object.keys(boxesMeta).map(Number).filter(n => n >= 1 && n <= 4).sort(); + const inferredFromStats = Array.from( + new Set( + Object.entries(stats) + .filter(([sid, s]) => /^[1-4][A-D]$/.test(sid) && !!s && (((s.total_meters || 0) > 0) || ((s.total_kg || 0) > 0) || !!s.last_used_at)) + .map(([sid]) => Number(sid[0])) + .filter(n => n >= 1 && n <= 4) + ) + ).sort(); + const boxIds = activeBoxIds.length ? activeBoxIds : inferredFromStats; + + if (!boxIds.length) { + const empty = document.createElement('div'); + empty.className = 'emptyState'; + empty.textContent = 'No CFS detected'; + wrap.appendChild(empty); + return; + } - const metaFor = (sid) => { - const m = (slots && slots[sid]) ? slots[sid] : {}; - const local = (state.slots && state.slots[sid]) ? state.slots[sid] : {}; - return { - present: (m.present ?? local.present ?? true), - material: ((m.material ?? local.material) || "").toString().toUpperCase(), - color: ((m.color ?? m.color_hex ?? local.color ?? local.color_hex) || "").toString().toLowerCase(), - remaining_g: (local.remaining_g ?? null), - spool_remaining_g: (local.spool_remaining_g ?? null), - spool_used_g: (local.spool_used_g ?? null), - spool_consumed_g: (local.spool_consumed_g ?? null), - }; - }; + for (const b of boxIds) { + const slotIds = ['A', 'B', 'C', 'D'].map(l => `${b}${l}`); - for (const sid of slotIds) { - const m = metaFor(sid); - const epoch = Number(((state.slots || {})[sid] || {}).spool_epoch || 0); - const rawEntries = Array.isArray(history[sid]) ? history[sid] : []; - const entries = rawEntries.filter(e => Number((e || {}).epoch || 0) === epoch).slice(0,4); - - const card = document.createElement("div"); - card.className = "histSlot"; - - const head = document.createElement("div"); - head.className = "histHead"; - - const title = document.createElement("div"); - title.className = "histTitle"; - - const sw = document.createElement("div"); - sw.className = "swatch"; - sw.style.width = "22px"; - sw.style.height = "22px"; - sw.style.background = m.color || "#2a3442"; - title.appendChild(sw); - - const nm = document.createElement("div"); - nm.className = "histSlotName"; - nm.textContent = `Box ${sid[0]} · Slot ${sid[1]}` + (sid === active ? " · aktiv" : ""); - title.appendChild(nm); - - head.appendChild(title); - - // totals - let sumMm = 0; - let sumG = 0; - for (const e of entries) { - sumMm += Number(e.used_mm || 0); - sumG += Number(e.used_g || 0); + let boxMeters = 0, boxKg = 0; + for (const sid of slotIds) { + const s = stats[sid]; + if (s) { boxMeters += s.total_meters || 0; boxKg += s.total_kg || 0; } } - const meta = document.createElement("div"); - meta.className = "histMeta"; - // Primary: grams (this is what matters). Keep meters as detail in entry. - meta.textContent = entries.length ? `${fmtG(sumG)}` : "—"; - head.appendChild(meta); - card.appendChild(head); - const list = document.createElement("div"); - list.className = "histList"; - - if (!entries.length) { - const empty = document.createElement("div"); - empty.className = "tag muted"; - empty.textContent = "Noch keine Daten"; - list.appendChild(empty); - } else { - for (const e of entries) { - const det = document.createElement("details"); - det.className = "histEntry"; - det.dataset.key = `${sid}:${String(e.ts || '')}:${String(e.job || '')}`; - - const sum = document.createElement("summary"); - const row = document.createElement("div"); - row.className = "histRow"; - - const job = document.createElement("div"); - job.className = "histJob"; - job.textContent = (e.job || "(ohne name)"); - - const nums = document.createElement("div"); - nums.className = "histNums"; - const mmTxt = Number(e.used_mm || 0) > 0 ? ` (${fmtMm(e.used_mm)})` : ""; - nums.textContent = `${fmtG(e.used_g)}${mmTxt}`; - - row.appendChild(job); - row.appendChild(nums); - sum.appendChild(row); - det.appendChild(sum); - - const sub = document.createElement("div"); - sub.className = "histSub"; - const when = document.createElement("span"); - when.textContent = "🕒 " + fmtTs(e.ts); - const res = document.createElement("span"); - res.textContent = "✅ " + String(e.result || ""); - const mat = document.createElement("span"); - mat.textContent = "🧵 " + (m.material || "—") + (m.color ? " " + m.color.toUpperCase() : ""); - sub.appendChild(when); - sub.appendChild(mat); - if (e.result) sub.appendChild(res); - det.appendChild(sub); - - list.appendChild(det); - } + const boxDiv = document.createElement('div'); + boxDiv.className = 'cfsBox'; + + const head = document.createElement('div'); + head.className = 'cfsBoxHead'; + const headLabel = document.createElement('span'); + headLabel.textContent = `CFS Box ${b}`; + const headTotals = document.createElement('span'); + headTotals.className = 'cfsBoxTotals'; + headTotals.textContent = `${boxMeters.toFixed(1)} m · ${fmtG(boxKg * 1000)}`; + head.appendChild(headLabel); + head.appendChild(headTotals); + boxDiv.appendChild(head); + + for (const sid of slotIds) { + const s = stats[sid] || {}; + const row = document.createElement('div'); + row.className = 'cfsSlotRow'; + + const label = document.createElement('span'); + label.className = 'cfsSlotLabel'; + label.textContent = sid; + + const meters = document.createElement('span'); + meters.className = 'cfsSlotMeters'; + meters.textContent = ((s.total_meters || 0)).toFixed(1) + ' m'; + + const kg = document.createElement('span'); + kg.className = 'cfsSlotKg'; + kg.textContent = fmtG((s.total_kg || 0) * 1000); + + const last = document.createElement('span'); + last.className = 'cfsSlotLast'; + last.textContent = fmtRelative(s.last_used_at || null); + + row.appendChild(label); + row.appendChild(meters); + row.appendChild(kg); + row.appendChild(last); + boxDiv.appendChild(row); } - card.appendChild(list); - wrap.appendChild(card); + wrap.appendChild(boxDiv); } } -function render(state) { - const printerBadge = $("printerBadge"); - const cfsBadge = $("cfsBadge"); +function hexBrightness(hex) { + const h = (hex || '').replace('#', ''); + if (h.length !== 6) return 128; + const r = parseInt(h.substring(0, 2), 16); + const g = parseInt(h.substring(2, 4), 16); + const b = parseInt(h.substring(4, 6), 16); + return (r * 299 + g * 587 + b * 114) / 1000; +} + +function makeSpoolSvg(meta) { + const present = meta.present !== false; + const rawColor = meta.color || ''; + const hasColor = present && rawColor && rawColor !== '#2a3442' && rawColor.length >= 4; + + if (!hasColor) { + // Empty slot — dark disk with diagonal slash + return ` + + + `; + } + const c = rawColor.startsWith('#') ? rawColor : '#' + rawColor; + const bright = hexBrightness(c); + const tick = bright > 145 ? 'rgba(0,0,0,0.28)' : 'rgba(255,255,255,0.18)'; + + // Filament fill radius: area-proportional so it matches how a real spool empties. + // At 100% the colored disk reaches the outer rim (r=36); at 0% it shrinks to the hub (r=10). + const pct = (meta.percent != null) ? Math.max(0, Math.min(100, meta.percent)) / 100 : 1.0; + const R_OUTER = 36, R_CORE = 10; + const filR = Math.round(Math.sqrt(R_CORE * R_CORE + (R_OUTER * R_OUTER - R_CORE * R_CORE) * pct) * 10) / 10; + const filamentDisk = filR > R_CORE + 0.5 ? `` : ''; + + return ` + + ${filamentDisk} + + + + + + + + `; +} + +function renderPrinter(printerId, state) { + const block = document.createElement("section"); + block.className = "printerBlock"; + if (printerId) block.dataset.printerId = printerId; + + const head = document.createElement("div"); + head.className = "printerHead"; + + const titleWrap = document.createElement("div"); + titleWrap.className = "printerTitleWrap"; + const nameEl = document.createElement("div"); + nameEl.className = "printerName"; + nameEl.textContent = state.printer_name || printerId || "Printer"; + const metaEl = document.createElement("div"); + metaEl.className = "printerMeta"; + metaEl.textContent = [printerId, state.printer_firmware].filter(Boolean).join(" · "); + titleWrap.appendChild(nameEl); + titleWrap.appendChild(metaEl); + head.appendChild(titleWrap); + + const badges = document.createElement("div"); + badges.className = "printerBadges"; + const pBadge = document.createElement("div"); + pBadge.className = "badge"; + const cfsBadge = document.createElement("div"); + cfsBadge.className = "badge"; const printerOk = !!state.printer_connected; - badge(printerBadge, printerOk ? "Printer: verbunden" : "Printer: getrennt", printerOk ? "ok" : "bad"); + badge(pBadge, printerOk ? "Printer: connected" : "Printer: disconnected", printerOk ? "ok" : "bad"); if (!printerOk && state.printer_last_error) { - printerBadge.textContent += " (" + state.printer_last_error + ")"; + pBadge.textContent += " (" + state.printer_last_error + ")"; } - const cfsOk = !!state.cfs_connected; badge( cfsBadge, - cfsOk ? ("CFS: erkannt · " + fmtTs(state.cfs_last_update)) : "CFS: —", + cfsOk ? `CFS: detected · ${fmtTs(state.cfs_last_update)}` : "CFS: —", cfsOk ? "ok" : "warn" ); + badges.appendChild(pBadge); + badges.appendChild(cfsBadge); + head.appendChild(badges); + block.appendChild(head); + + const layout = document.createElement("div"); + layout.className = "layout"; + + const leftCol = document.createElement("div"); + leftCol.className = "leftCol"; + const boxesGrid = document.createElement("section"); + boxesGrid.className = "grid"; + leftCol.appendChild(boxesGrid); + + const activeCard = document.createElement("section"); + activeCard.className = "card"; + activeCard.style.marginTop = "16px"; + const activeHead = document.createElement("div"); + activeHead.className = "cardHead"; + const activeTitle = document.createElement("div"); + activeTitle.className = "cardTitle"; + activeTitle.textContent = "Active"; + const activeMeta = document.createElement("div"); + activeMeta.className = "cardMeta"; + activeMeta.textContent = "—"; + activeHead.appendChild(activeTitle); + activeHead.appendChild(activeMeta); + activeCard.appendChild(activeHead); + const activeRow = document.createElement("div"); + activeRow.className = "activeRow"; + activeCard.appendChild(activeRow); + const activeLive = document.createElement("div"); + activeLive.className = "activeLive"; + activeLive.style.display = "none"; + activeCard.appendChild(activeLive); + leftCol.appendChild(activeCard); + + const rightCol = document.createElement("aside"); + rightCol.className = "rightCol"; + const statsCard = document.createElement("section"); + statsCard.className = "card"; + const statsHead = document.createElement("div"); + statsHead.className = "cardHead"; + const statsTitle = document.createElement("div"); + statsTitle.className = "cardTitle"; + statsTitle.textContent = "Status"; + const statsMeta = document.createElement("div"); + statsMeta.className = "cardMeta"; + statsHead.appendChild(statsTitle); + statsHead.appendChild(statsMeta); + statsCard.appendChild(statsHead); + const history = document.createElement("div"); + history.className = "history"; + statsCard.appendChild(history); + rightCol.appendChild(statsCard); + + layout.appendChild(leftCol); + layout.appendChild(rightCol); + block.appendChild(layout); // 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 boxesGrid = $("boxesGrid"); - boxesGrid.innerHTML = ""; + const localSlots = state.slots || {}; + const slots = (state.cfs_slots && Object.keys(state.cfs_slots).length) ? state.cfs_slots : localSlots; + const active = state.cfs_active_slot || null; // Determine which CFS boxes are actually connected. const boxesInfo = (slots && slots._boxes) ? slots._boxes : {}; + const envHistoryByBox = (state.cfs_env_history && typeof state.cfs_env_history === 'object') ? state.cfs_env_history : {}; const connectedBoxes = []; for (const n of ["1", "2", "3", "4"]) { const bi = boxesInfo[n]; if (bi && bi.connected === true) connectedBoxes.push(n); } - // Fallback: if firmware doesn't provide box connection metadata, show Box 1 & 2. - if (!connectedBoxes.length) connectedBoxes.push("1", "2"); + // Fallback: infer from live slot signals if firmware omits box metadata. + if (!connectedBoxes.length) connectedBoxes.push(...inferLiveCfsBoxes(state.cfs_slots || {})); const metaFor = (sid) => { // We render slots primarily from Creality CFS data (state.cfs_slots), // BUT spool tracking (remaining/consumed + reference points) lives in state.slots. // Therefore we must merge both. const m = (slots && slots[sid]) ? slots[sid] : {}; - const local = (state.slots && state.slots[sid]) ? state.slots[sid] : {}; + const local = (localSlots && localSlots[sid]) ? localSlots[sid] : {}; + const hasLiveCfs = !!(state.cfs_slots && Object.keys(state.cfs_slots).length); + const localHasSpool = !!( + local.spoolman_id || + local.name || + local.manufacturer || + (String(local.material || "").toUpperCase() && String(local.material || "").toUpperCase() !== "OTHER") + ); + const defaultPresent = hasLiveCfs ? false : localHasSpool; + let present = (m.present ?? local.present ?? defaultPresent); + const wsState = Number(m.state ?? -1); + const wsRfid = String(m.rfid ?? "").trim(); + const wsRfidMissing = ["", "0", "00", "000", "0000", "00000", "000000"].includes(wsRfid); + const mergedMaterial = String((m.material ?? local.material) || "").toUpperCase(); + const mergedName = String((m.name ?? local.name) || "").trim(); + const mergedVendor = String((m.manufacturer ?? m.vendor ?? local.manufacturer ?? local.vendor) || "").trim(); + const looksLikeEmptyManual = wsState === 1 && wsRfidMissing && !mergedName && !mergedVendor && (!mergedMaterial || mergedMaterial === "OTHER"); + if (looksLikeEmptyManual) present = false; // normalize fields from either cfs_slots or local slots const out = { - present: (m.present ?? local.present ?? true), - material: ((m.material ?? local.material) || "").toString().toUpperCase(), - color: ((m.color ?? m.color_hex ?? local.color ?? local.color_hex) || "").toString().toLowerCase(), - - // spool fields (local bookkeeping) - remaining_g: (local.remaining_g ?? null), - spool_remaining_g: (local.spool_remaining_g ?? null), - spool_used_g: (local.spool_used_g ?? null), - spool_consumed_g: (local.spool_consumed_g ?? null), + present, + material: present === false ? "" : ((m.material ?? local.material) || "").toString().toUpperCase(), + color: present === false ? "" : ((m.color ?? m.color_hex ?? local.color ?? local.color_hex) || "").toString().toLowerCase(), + + // spool epoch (for roll-change tracking) spool_epoch: (local.spool_epoch ?? null), - spool_ref_remaining_g: (local.spool_ref_remaining_g ?? null), - spool_ref_consumed_g: (local.spool_ref_consumed_g ?? null), + + // Spoolman + spoolman_id: (local.spoolman_id ?? null), + name: present === false ? "" : (local.name ?? ''), + manufacturer: present === false ? "" : (local.manufacturer ?? local.vendor ?? ''), + + // CFS percent remaining from WS data + percent: (m.percent != null ? m.percent : null), }; return out; }; - function makeBoxCard(boxNum) { - const card = document.createElement("div"); - card.className = "card"; + function makeSlotPod(sid, m, isAct) { + const pod = document.createElement("div"); + pod.className = "slotPod" + (isAct ? " active" : ""); + pod.dataset.slotid = sid; + + // Slot ID badge + const idBadge = document.createElement("div"); + idBadge.className = "slotPodId"; + idBadge.textContent = sid; + pod.appendChild(idBadge); + + // Spool graphic + const spoolWrap = document.createElement("div"); + spoolWrap.className = "slotPodSpool"; + spoolWrap.innerHTML = makeSpoolSvg(m); + pod.appendChild(spoolWrap); + + // Material — only shown when slot is occupied + const matEl = document.createElement("div"); + matEl.className = "slotPodMaterial"; + matEl.textContent = m.present === false ? "" : (m.material || "—"); + pod.appendChild(matEl); + + // Percent remaining (if available from CFS/WS) + if (m.present !== false && m.percent != null) { + const pctEl = document.createElement("div"); + pctEl.className = "slotPodPct"; + pctEl.textContent = m.percent + "%"; + pod.appendChild(pctEl); + } + + // Spoolman link indicator dot + const linkDot = document.createElement("div"); + linkDot.className = "slotPodLink" + (m.spoolman_id ? " linked" : ""); + linkDot.title = m.spoolman_id ? "Linked to Spoolman #" + m.spoolman_id : "Not linked to Spoolman"; + pod.appendChild(linkDot); + + pod.addEventListener("click", (ev) => { + ev.preventDefault(); + openSpoolModal(sid, m, printerId); + }); - const head = document.createElement("div"); - head.className = "cardHead"; + return pod; + } - const title = document.createElement("div"); - title.className = "cardTitle"; - title.textContent = `Box ${boxNum}`; + function makeBoxCard(boxNum) { + const row = document.createElement("div"); + row.className = "boxRow"; - const meta = document.createElement("div"); - meta.className = "cardMeta"; + // Left: box header showing box number + env data + const header = document.createElement("div"); + header.className = "boxHeader"; + + const hTitle = document.createElement("div"); + hTitle.className = "boxHeaderTitle"; + hTitle.textContent = `Box ${boxNum}`; + header.appendChild(hTitle); const bi = boxesInfo[boxNum] || {}; - // Temperature / humidity per box (Creality reports these as numbers/strings) + const boxHistory = Array.isArray(envHistoryByBox[String(boxNum)]) ? envHistoryByBox[String(boxNum)] : []; const tC = bi.temperature_c; const rh = bi.humidity_pct; - const hasT = (typeof tC === "number" && !Number.isNaN(tC)); - const hasRh = (typeof rh === "number" && !Number.isNaN(rh)); - - // Render as compact "chips" (bigger + clearer than plain text) - if (hasT) { - const sp = document.createElement("span"); - sp.className = "envItem"; - sp.textContent = `🌡 ${Math.round(tC)}°C`; - meta.appendChild(sp); + if (typeof tC === "number" && !Number.isNaN(tC)) { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = "boxEnvChip boxEnvChipBtn"; + chip.textContent = `🌡 ${Math.round(tC)}°C`; + chip.title = "Show temperature history"; + chip.addEventListener("click", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + openEnvChartModal({ + printerId, + printerName: state.printer_name || printerId || "Printer", + boxId: String(boxNum), + metricKey: "temperature_c", + history: boxHistory, + }); + }); + header.appendChild(chip); } - if (hasRh) { - const sp = document.createElement("span"); - sp.className = "envItem"; - sp.textContent = `💧 ${Math.round(rh)}%`; - meta.appendChild(sp); + if (typeof rh === "number" && !Number.isNaN(rh)) { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = "boxEnvChip boxEnvChipBtn"; + chip.textContent = `💧 ${Math.round(rh)}%`; + chip.title = "Show humidity history"; + chip.addEventListener("click", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + openEnvChartModal({ + printerId, + printerName: state.printer_name || printerId || "Printer", + boxId: String(boxNum), + metricKey: "humidity_pct", + history: boxHistory, + }); + }); + header.appendChild(chip); } + row.appendChild(header); - head.appendChild(title); - if (meta.childNodes.length) head.appendChild(meta); - card.appendChild(head); - + // Right: 4 slot pods const slotsWrap = document.createElement("div"); - slotsWrap.className = "slots"; + slotsWrap.className = "boxSlots"; + for (const letter of ["A", "B", "C", "D"]) { const sid = `${boxNum}${letter}`; - slotsWrap.appendChild(slotEl(sid, `Slot ${letter}`, metaFor(sid), sid === active)); + const m = metaFor(sid); + const isAct = sid === active; + slotsWrap.appendChild(makeSlotPod(sid, m, isAct)); } - card.appendChild(slotsWrap); - return card; + + row.appendChild(slotsWrap); + return row; + } + + function makeSpoolInputCard() { + const row = document.createElement("div"); + row.className = "boxRow"; + + const header = document.createElement("div"); + header.className = "boxHeader"; + const hTitle = document.createElement("div"); + hTitle.className = "boxHeaderTitle"; + hTitle.textContent = "Spool"; + header.appendChild(hTitle); + row.appendChild(header); + + const slotsWrap = document.createElement("div"); + slotsWrap.className = "boxSlots boxSlotsSingle"; + const m = metaFor(PRINTER_SPOOL_SLOT); + const isAct = PRINTER_SPOOL_SLOT === active; + slotsWrap.appendChild(makeSlotPod(PRINTER_SPOOL_SLOT, m, isAct)); + row.appendChild(slotsWrap); + return row; } for (const b of connectedBoxes) { boxesGrid.appendChild(makeBoxCard(b)); } + boxesGrid.appendChild(makeSpoolInputCard()); - // Right-side history panel - renderHistory(state, slots, connectedBoxes); - renderMoonHistory(state, connectedBoxes); + // Right-side CFS stats panel + renderCfsStats(state, history); // Active card - const activeRow = $("activeRow"); - activeRow.innerHTML = ""; - const activeLive = $("activeLive"); - if (activeLive) { - activeLive.style.display = "none"; - activeLive.innerHTML = ""; - } - if (active && (slots[active] || state.slots[active])) { + if (active && (slots[active] || localSlots[active])) { const m = metaFor(active); - activeRow.appendChild(slotEl(active, `Box ${active[0]} · Slot ${active[1]}`, m, true)); - $("activeMeta").textContent = m.material ? (m.material + " · " + (m.color ? m.color.toUpperCase() : "")) : "—"; - - // Live consumption while printing: use slot mm deltas (job_track_slot_mm) - // and convert to grams using the current job's g/mm ratio (if available). - const isPrinting = String(state.job_track_last_state || "").toLowerCase() === "printing"; - const slotMm = (state.job_track_slot_mm && typeof state.job_track_slot_mm === 'object') ? Number(state.job_track_slot_mm[active] || 0) : 0; - const jobMm = Number(state.current_job_filament_mm || 0); - const jobG = Number(state.current_job_filament_g || 0); - const ratio = (jobMm > 0 && jobG > 0) ? (jobG / jobMm) : 0; - - const slotM = slotMm > 0 ? (slotMm / 1000) : 0; - - // Prefer backend-provided per-slot grams (robust for multi-color and firmware quirks) - const slotG_direct = (state.job_track_slot_g && typeof state.job_track_slot_g === 'object') ? Number(state.job_track_slot_g[active] || 0) : 0; - const slotG = (slotG_direct > 0) ? slotG_direct : ((ratio > 0 && slotMm > 0) ? (slotMm * ratio) : 0); - - if (activeLive && isPrinting && slotMm > 0) { - const p1 = document.createElement('span'); - p1.className = 'pill'; - p1.textContent = `Live: ${slotM.toFixed(slotM < 10 ? 2 : 1)} m`; - activeLive.appendChild(p1); - if (slotG > 0) { - const p2 = document.createElement('span'); - p2.className = 'pill'; - p2.textContent = `≈ ${slotG.toFixed(1)} g`; - activeLive.appendChild(p2); + activeRow.appendChild(slotEl(active, slotTitle(active), m, true, printerId)); + activeMeta.textContent = m.material ? (m.material + " · " + (m.color ? m.color.toUpperCase() : "")) : "—"; + } else { + activeMeta.textContent = "—"; + } + + return block; +} + +function renderRecentJobsCard(printers) { + const rows = []; + for (const p of printers) { + const pid = p.id || p.printer_id || p.host || ""; + const st = p.state || p; + const hist = Array.isArray(st.job_history) ? st.job_history : []; + for (const j of hist) { + if (!j || typeof j !== "object") continue; + const startedAt = Number(j.started_at || 0); + const endedAt = Number(j.ended_at || 0); + const spools = Array.isArray(j.spools) ? j.spools : []; + const totalMeters = Number(j.total_meters || 0); + const totalGrams = Number(j.total_grams || 0); + rows.push({ + startedAt, + endedAt, + printer: String(j.printer_id || pid || "—"), + jobName: String(j.job_name || ""), + source: String(j.source || ""), + needsLink: !!j.needs_link, + spools, + totalMeters, + totalGrams, + }); + } + } + + rows.sort((a, b) => (b.endedAt || 0) - (a.endedAt || 0)); + const top = rows.slice(0, 10); + + const block = document.createElement("section"); + block.className = "printerBlock"; + const head = document.createElement("div"); + head.className = "printerHead"; + const titleWrap = document.createElement("div"); + titleWrap.className = "printerTitleWrap"; + const title = document.createElement("div"); + title.className = "printerName"; + title.textContent = "Recent Jobs"; + const meta = document.createElement("div"); + meta.className = "printerMeta"; + meta.textContent = "Last 10 completed jobs"; + titleWrap.appendChild(title); + titleWrap.appendChild(meta); + head.appendChild(titleWrap); + + const actions = document.createElement("div"); + const allLink = document.createElement("a"); + allLink.className = "btn mini"; + allLink.href = "/jobs"; + allLink.textContent = "View all"; + actions.appendChild(allLink); + head.appendChild(actions); + block.appendChild(head); + + const body = document.createElement("section"); + body.className = "card"; + const list = document.createElement("div"); + list.className = "moonHist"; + body.appendChild(list); + block.appendChild(body); + + if (!top.length) { + const empty = document.createElement("div"); + empty.className = "emptyState"; + empty.textContent = "No completed jobs yet."; + list.appendChild(empty); + return block; + } + + for (const j of top) { + const entry = document.createElement("div"); + entry.className = "moonEntry"; + + const row = document.createElement("div"); + row.className = "moonRow"; + const left = document.createElement("div"); + left.className = "moonJob"; + left.textContent = `Printer: ${j.printer}${j.jobName ? " · " + j.jobName : ""}`; + const right = document.createElement("div"); + right.className = "moonNums"; + right.textContent = `${j.totalMeters.toFixed(1)} m · ${fmtG(j.totalGrams)}`; + row.appendChild(left); + row.appendChild(right); + entry.appendChild(row); + + const sub = document.createElement("div"); + sub.className = "moonSub"; + const flags = []; + if (j.source === 'moonraker_history') flags.push('Recovered while offline'); + if (j.needsLink) flags.push('Needs spool link'); + const suffix = flags.length ? ` · ${flags.join(' · ')}` : ''; + sub.textContent = `Start: ${fmtTs(j.startedAt)} · End: ${fmtTs(j.endedAt)} · Print Time: ${fmtDuration(j.startedAt, j.endedAt)}${suffix}`; + entry.appendChild(sub); + + const spoolList = document.createElement("div"); + spoolList.className = "moonSpoolList"; + if (!j.spools.length) { + const empty = document.createElement("div"); + empty.className = "moonSpoolEmpty"; + empty.textContent = "No spool usage recorded"; + spoolList.appendChild(empty); + } else { + for (const s of j.spools) { + const spoolRow = document.createElement("div"); + spoolRow.className = "moonSpoolRow"; + + const info = document.createElement("div"); + info.className = "moonSpoolInfo"; + const swatch = document.createElement("span"); + swatch.className = "moonSpoolSwatch"; + const col = normalizeHexColor(s.color_hex || s.color); + if (col) swatch.style.background = col; + info.appendChild(swatch); + + const textWrap = document.createElement("div"); + textWrap.className = "moonSpoolTextWrap"; + const label = document.createElement("div"); + label.className = "moonSpoolLabel"; + const spoolId = Number(s.spoolman_id || 0); + const material = String(s.material || "").trim().toUpperCase(); + label.textContent = `${recentJobSlotLabel(s.slot)} · ${spoolId > 0 ? "#" + spoolId : "not linked"}${material ? " · " + material : ""}`; + const meta = document.createElement("div"); + meta.className = "moonSpoolMeta"; + meta.textContent = `${(Number(s.meters || 0)).toFixed(2)} m · ${fmtG(Number(s.grams || 0))}`; + textWrap.appendChild(label); + textWrap.appendChild(meta); + info.appendChild(textWrap); + spoolRow.appendChild(info); + + const canRelink = spoolmanConfigured && !!j.printer && !!s.slot; + const btn = document.createElement("button"); + btn.className = "btn mini"; + btn.textContent = spoolId > 0 ? "Relink" : "Link"; + if (!canRelink) btn.disabled = true; + btn.onclick = async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + await openHistoryRelinkModal({ + printerId: j.printer, + endedAt: j.endedAt, + slot: String(s.slot || ""), + currentSpoolId: spoolId || null, + grams: Number(s.grams || 0), + meters: Number(s.meters || 0), + material: String(s.material || "").trim().toUpperCase(), + manufacturer: String(s.manufacturer || ""), + }); + }; + spoolRow.appendChild(btn); + spoolList.appendChild(spoolRow); } - activeLive.style.display = 'flex'; } - } else { - $("activeMeta").textContent = "—"; + entry.appendChild(spoolList); + + list.appendChild(entry); } + + return block; +} + +function render(ui) { + const printers = (ui && ui.printers) ? ui.printers : []; + + // Spoolman external link + const smExtLink = $("spoolmanExtLink"); + if (smExtLink) { + if (ui && ui.spoolman_url) { + smExtLink.href = ui.spoolman_url; + smExtLink.style.display = ''; + } else { + smExtLink.style.display = 'none'; + } + } + + // Update heading / title + const printerTitle = $("printerTitle"); + if (printerTitle) printerTitle.textContent = "CFSync"; + document.title = printers.length ? `CFSync · ${printers.length} printers` : "CFSync"; + const sub = $("printerSubtitle"); + if (sub) { + sub.textContent = printers.length ? `${printers.length} printer${printers.length === 1 ? "" : "s"} configured` : "No printers configured"; + } + + const printerBadge = $("printerBadge"); + const cfsBadge = $("cfsBadge"); + const total = printers.length; + const connected = printers.filter(p => (p.state || p).printer_connected).length; + const cfsSummary = printers.reduce((acc, p) => { + const st = p.state || p || {}; + const connectedBoxes = connectedCfsBoxesForState(st); + acc.boxes += connectedBoxes.length; + if (connectedBoxes.length) acc.printers += 1; + return acc; + }, { boxes: 0, printers: 0 }); + + if (printerBadge) { + if (!total) { + badge(printerBadge, "Printers: —", "warn"); + } else { + const cls = connected === total ? "ok" : (connected > 0 ? "warn" : "bad"); + badge(printerBadge, `Printers: ${connected}/${total} online`, cls); + } + } + if (cfsBadge) { + if (!total) { + badge(cfsBadge, "CFS: —", "warn"); + } else { + const boxWord = cfsSummary.boxes === 1 ? "box" : "boxes"; + badge( + cfsBadge, + `CFS: ${cfsSummary.boxes} ${boxWord} on ${cfsSummary.printers}/${total} printers`, + cfsSummary.boxes > 0 ? "ok" : "warn" + ); + } + } + + const wrap = $("printersWrap"); + if (!wrap) return; + wrap.innerHTML = ""; + if (!printers.length) { + const empty = document.createElement("div"); + empty.className = "emptyState"; + empty.textContent = "No printers configured. Set printer_urls (or printers) in data/config.json and reload."; + wrap.appendChild(empty); + return; + } + + for (const p of printers) { + const pid = p.id || p.printer_id || p.host || ""; + const st = p.state || p; + wrap.appendChild(renderPrinter(pid, st)); + } + wrap.appendChild(renderRecentJobsCard(printers)); } async function tick() { try { - // Preserve open accordions / select values so assignment doesn't collapse - // during auto-refresh. - captureUiState(); - const rightCol = document.querySelector('.rightCol'); - 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); - restoreUiState(); - if (rightCol && scrollTop != null) rightCol.scrollTop = scrollTop; + const st = j.result || j; + spoolmanConfigured = !!st.spoolman_configured; + render(st); } catch (e) { - badge($("printerBadge"), "Printer: —", "warn"); - badge($("cfsBadge"), "CFS: —", "warn"); + const pb = $("printerBadge"); + const cb = $("cfsBadge"); + if (pb) badge(pb, 'Printers: —', "warn"); + if (cb) badge(cb, 'CFS: —', "warn"); } } @@ -832,9 +1806,80 @@ function initRefreshControls() { applyRefreshTimer(); } +function initFluiddBookmarklet() { + const origin = window.location.origin; + const code = "javascript:(function(){window.CFSYNC_URL='" + origin + "';" + + "var s=document.createElement('script');" + + "s.src='" + origin + "/static/fluidd-panel.js?v=1&t='+Date.now();" + + "document.head.appendChild(s);})();"; + + const link = document.getElementById('fluiddBookmarklet'); + if (link) link.href = code; + + const btn = document.getElementById('fluiddCopyBtn'); + if (btn) { + btn.onclick = async () => { + try { + await navigator.clipboard.writeText(code); + const prev = btn.textContent; + btn.textContent = '✓'; + setTimeout(() => { btn.textContent = prev; }, 2000); + } catch (_) { + prompt('Copy this bookmarklet URL and save it as a browser bookmark:', code); + } + }; + } +} + +function initFluiddUserscript() { + const btn = document.getElementById('fluiddUserscriptBtn'); + if (!btn) return; + btn.onclick = async () => { + const origin = window.location.origin; + const fluiddUrl = prompt( + 'Enter your Fluidd URL (e.g. http://192.168.1.100)\nThis will be used for the @match rule so the script only runs on Fluidd:', + 'http://192.168.1.100' + ); + if (!fluiddUrl) return; + + const matchUrl = fluiddUrl.replace(/\/$/, '') + '/*'; + const script = [ + '// ==UserScript==', + '// @name CFSync — Fluidd Panel', + '// @namespace http://tampermonkey.net/', + '// @version 1.0', + '// @description Shows live CFS slot status in Fluidd\'s Runout Sensors card', + '// @match ' + matchUrl, + '// @grant none', + '// ==/UserScript==', + '', + '(function () {', + " 'use strict';", + " window.CFSYNC_URL = '" + origin + "';", + " var s = document.createElement('script');", + " s.src = window.CFSYNC_URL + '/static/fluidd-panel.js?v=1&t=' + Date.now();", + " document.head.appendChild(s);", + '})();', + ].join('\n'); + + try { + await navigator.clipboard.writeText(script); + const prev = btn.textContent; + btn.textContent = '✓ Copied!'; + setTimeout(() => { btn.textContent = prev; }, 2500); + } catch (_) { + prompt('Copy this userscript and paste it into Tampermonkey → New Script:', script); + } + }; +} + function boot() { initSpoolModal(); + initHistoryRelinkModal(); + initEnvChartModal(); initRefreshControls(); + initFluiddBookmarklet(); + initFluiddUserscript(); tick(); } diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..603e141 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/fluidd-panel.js b/static/fluidd-panel.js new file mode 100644 index 0000000..211bdb8 --- /dev/null +++ b/static/fluidd-panel.js @@ -0,0 +1,372 @@ +// CFSync Fluidd Panel — injects live CFS slot status into Fluidd +// +// Usage (bookmarklet): set window.CFSYNC_URL before loading this script. +// The bookmarklet is generated by CFSync's own UI (footer link). +// +// What it does: +// 1. Finds Fluidd's "Runout Sensors" card (Vuetify v2 & v3) +// 2. Replaces its content with a compact CFS slot grid from CFSync's API +// 3. Polls /api/ui/state every 3 s; re-injects if Fluidd re-renders the card +// 4. Falls back to a floating panel if the card is not found after 15 s + +(function () { + 'use strict'; + if (window.__cfsync_fluidd) return; + window.__cfsync_fluidd = true; + + const BASE = (window.CFSYNC_URL || '').replace(/\/$/, ''); + if (!BASE) { + console.error('[CFSync panel] window.CFSYNC_URL is not set. Load via the bookmarklet generated by CFSync.'); + return; + } + + const POLL_MS = 3000; + const PRINTER_SPOOL_SLOT = 'SP'; + let slotsContainer = null; + let statusEl = null; + let pollTimer = null; + let injected = false; + + // ---------- Styles ---------- + const STYLE_ID = 'cfsync-panel-styles'; + if (!document.getElementById(STYLE_ID)) { + const s = document.createElement('style'); + s.id = STYLE_ID; + s.textContent = [ + '#cfsync-panel{font-family:inherit}', + '#cfsync-panel *{box-sizing:border-box}', + '.cfsp-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}', + '.cfsp-title{font-weight:700;font-size:13px;opacity:.9}', + '.cfsp-status{font-size:11px;padding:2px 7px;border-radius:4px;background:rgba(128,128,128,.15)}', + '.cfsp-status.ok{background:rgba(31,157,85,.18);color:#1a9b50}', + '.cfsp-status.err{background:rgba(214,69,69,.18);color:#c94444}', + '.cfsp-boxes{display:flex;flex-direction:column;gap:6px}', + '.cfsp-box{display:flex;align-items:stretch;gap:6px}', + '.cfsp-box-lbl{width:38px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;opacity:.5}', + '.cfsp-slots{display:flex;gap:4px;flex:1}', + '.cfsp-slot{flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;padding:6px 3px 5px;border-radius:8px;border:1px solid rgba(128,128,128,.2);min-width:0}', + '.cfsp-slot.active{border-color:rgba(31,157,85,.6);background:rgba(31,157,85,.07)}', + '.cfsp-dot{width:20px;height:20px;border-radius:50%;background:rgba(128,128,128,.25);border:1px solid rgba(255,255,255,.15);flex-shrink:0}', + '.cfsp-id{font-size:10px;font-weight:700;opacity:.6;margin-top:1px}', + '.cfsp-mat{font-size:10px;opacity:.75;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;text-align:center;line-height:1.2}', + '.cfsp-pct{font-size:10px;opacity:.5}', + '.cfsp-foot{margin-top:10px;text-align:right;font-size:11px;opacity:.4}', + '.cfsp-foot a{color:inherit;text-decoration:none}', + '.cfsp-foot a:hover{opacity:.75}', + // Floating fallback panel + '#cfsync-float{position:fixed;bottom:20px;right:20px;z-index:9999;min-width:260px;max-width:320px;', + 'border-radius:14px;border:1px solid rgba(128,128,128,.25);', + 'background:rgba(12,18,28,.94);backdrop-filter:blur(10px);', + 'color:#e8eef6;padding:12px 14px;box-shadow:0 8px 32px rgba(0,0,0,.45);font-size:13px}', + '#cfsync-float-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}', + '#cfsync-float-close{cursor:pointer;opacity:.5;font-size:16px;line-height:1;background:none;border:none;color:inherit;padding:0}', + '#cfsync-float-close:hover{opacity:1}', + ].join(''); + document.head.appendChild(s); + } + + // ---------- Build panel content ---------- + function buildPanel() { + const wrap = document.createElement('div'); + wrap.id = 'cfsync-panel'; + + const hdr = document.createElement('div'); + hdr.className = 'cfsp-header'; + const title = document.createElement('span'); + title.className = 'cfsp-title'; + title.textContent = 'CFS Slots'; + statusEl = document.createElement('span'); + statusEl.className = 'cfsp-status'; + statusEl.textContent = 'connecting…'; + hdr.appendChild(title); + hdr.appendChild(statusEl); + wrap.appendChild(hdr); + + slotsContainer = document.createElement('div'); + slotsContainer.className = 'cfsp-boxes'; + wrap.appendChild(slotsContainer); + + const foot = document.createElement('div'); + foot.className = 'cfsp-foot'; + foot.innerHTML = 'Open CFSync \u2197'; + wrap.appendChild(foot); + + return wrap; + } + + // ---------- Render API state into the panel ---------- + function renderState(state) { + if (!slotsContainer) return; + slotsContainer.innerHTML = ''; + + const cfsSlots = state.cfs_slots || {}; + const localSlots = state.slots || {}; + const active = state.cfs_active_slot || null; + const boxesMeta = (cfsSlots._boxes) ? cfsSlots._boxes : {}; + const hasLiveCfs = Object.keys(cfsSlots).length > 0; + + function inferLiveCfsBoxes() { + const out = new Set(); + for (const sid of Object.keys(cfsSlots || {})) { + if (!/^[1-4][A-D]$/.test(sid)) continue; + const m = cfsSlots[sid] || {}; + const rfid = String(m.rfid ?? '').trim(); + const hasLiveSignal = ( + m.present === true || + Number(m.state ?? 0) > 0 || + Number(m.selected ?? 0) === 1 || + m.percent != null || + (rfid && !['0', '00', '000', '0000', '00000', '000000'].includes(rfid)) + ); + if (hasLiveSignal) out.add(sid[0]); + } + return Array.from(out).sort(); + } + + function makePod(sid, cfs, local, isActive) { + const pod = document.createElement('div'); + pod.className = 'cfsp-slot' + (isActive ? ' active' : ''); + + const localHasSpool = !!( + local.spoolman_id || + local.name || + local.manufacturer || + (String(local.material || '').toUpperCase() && String(local.material || '').toUpperCase() !== 'OTHER') + ); + let present = (cfs.present ?? local.present ?? (hasLiveCfs ? false : localHasSpool)); + const wsState = Number(cfs.state ?? -1); + const wsRfid = String(cfs.rfid ?? '').trim(); + const wsRfidMissing = ['', '0', '00', '000', '0000', '00000', '000000'].includes(wsRfid); + const mergedMaterial = String((cfs.material || local.material || '')).toUpperCase(); + const mergedName = String((cfs.name || local.name || '')).trim(); + const mergedVendor = String((cfs.manufacturer || cfs.vendor || local.manufacturer || local.vendor || '')).trim(); + const looksLikeEmptyManual = wsState === 1 && wsRfidMissing && !mergedName && !mergedVendor && (!mergedMaterial || mergedMaterial === 'OTHER'); + if (looksLikeEmptyManual) present = false; + + // Color swatch + const dot = document.createElement('div'); + dot.className = 'cfsp-dot'; + const rawColor = (cfs.color || cfs.color_hex || local.color_hex || '').toString().toLowerCase(); + if (rawColor && present !== false) { + dot.style.background = rawColor.startsWith('#') ? rawColor : '#' + rawColor; + } + pod.appendChild(dot); + + // Slot ID + const idEl = document.createElement('div'); + idEl.className = 'cfsp-id'; + idEl.textContent = sid; + pod.appendChild(idEl); + + // Material + const mat = ((cfs.material || local.material) || '').toString().toUpperCase(); + if (present !== false && mat) { + const matEl = document.createElement('div'); + matEl.className = 'cfsp-mat'; + matEl.textContent = mat; + pod.appendChild(matEl); + } + + // Percent remaining + if (present !== false && cfs.percent != null) { + const pctEl = document.createElement('div'); + pctEl.className = 'cfsp-pct'; + pctEl.textContent = cfs.percent + '%'; + pod.appendChild(pctEl); + } + + return pod; + } + + // Which boxes are connected? + const connected = []; + for (const n of ['1', '2', '3', '4']) { + const b = boxesMeta[n]; + if (b && b.connected === true) connected.push(n); + } + if (!connected.length) connected.push(...inferLiveCfsBoxes()); + + for (const boxNum of connected) { + const row = document.createElement('div'); + row.className = 'cfsp-box'; + + const lbl = document.createElement('div'); + lbl.className = 'cfsp-box-lbl'; + lbl.textContent = 'Box ' + boxNum; + row.appendChild(lbl); + + const slotWrap = document.createElement('div'); + slotWrap.className = 'cfsp-slots'; + + for (const letter of ['A', 'B', 'C', 'D']) { + const sid = boxNum + letter; + const cfs = cfsSlots[sid] || {}; + const local = localSlots[sid] || {}; + const isActive = sid === active; + slotWrap.appendChild(makePod(sid, cfs, local, isActive)); + } + row.appendChild(slotWrap); + slotsContainer.appendChild(row); + } + + // Direct printer spool holder (single slot, outside the 4x4 CFS grid) + const spoolRow = document.createElement('div'); + spoolRow.className = 'cfsp-box'; + const spoolLbl = document.createElement('div'); + spoolLbl.className = 'cfsp-box-lbl'; + spoolLbl.textContent = 'Spool'; + spoolRow.appendChild(spoolLbl); + const spoolWrap = document.createElement('div'); + spoolWrap.className = 'cfsp-slots'; + spoolWrap.appendChild( + makePod( + PRINTER_SPOOL_SLOT, + cfsSlots[PRINTER_SPOOL_SLOT] || {}, + localSlots[PRINTER_SPOOL_SLOT] || {}, + PRINTER_SPOOL_SLOT === active + ) + ); + spoolRow.appendChild(spoolWrap); + slotsContainer.appendChild(spoolRow); + + if (statusEl) { + const ok = !!state.cfs_connected; + statusEl.textContent = ok ? 'connected' : 'disconnected'; + statusEl.className = 'cfsp-status ' + (ok ? 'ok' : 'err'); + } + } + + // ---------- Poll CFSync API ---------- + async function poll() { + try { + const r = await fetch(BASE + '/api/ui/state', { cache: 'no-store' }); + if (!r.ok) throw new Error('HTTP ' + r.status); + const j = await r.json(); + renderState(j.result || j); + } catch (_e) { + if (statusEl) { statusEl.textContent = 'error'; statusEl.className = 'cfsp-status err'; } + } + } + + function startPolling() { + if (pollTimer) return; + poll(); + pollTimer = setInterval(poll, POLL_MS); + } + + // ---------- Find Fluidd's Runout Sensors card ---------- + // Vuetify v2 uses .v-card__title; Vuetify v3 uses .v-card-title or .v-toolbar-title__placeholder + const TITLE_SELECTORS = [ + '.v-card__title', + '.v-card-title', + '.v-toolbar-title__placeholder', + '.v-toolbar-title', + ]; + const TITLE_PATTERNS = ['runout', 'filament sensor', 'filament sensors']; + + function findCardBody() { + for (const sel of TITLE_SELECTORS) { + for (const el of document.querySelectorAll(sel)) { + const txt = (el.textContent || '').trim().toLowerCase(); + if (TITLE_PATTERNS.some((p) => txt.includes(p))) { + // Walk up to the Vuetify card root + let node = el; + for (let i = 0; i < 12; i++) { + node = node.parentElement; + if (!node) break; + if ( + node.classList.contains('v-card') || + node.classList.contains('v-sheet') || + node.getAttribute('role') === 'group' + ) { + const body = node.querySelector('.v-card__text, .v-card-text'); + return body || node; + } + } + } + } + } + return null; + } + + // ---------- Inject panel into target card ---------- + function inject() { + const target = findCardBody(); + if (!target) return false; + // Already injected into this element? + if (target.querySelector('#cfsync-panel')) { + startPolling(); + return true; + } + target.innerHTML = ''; + target.appendChild(buildPanel()); + injected = true; + startPolling(); + return true; + } + + // ---------- Floating fallback panel ---------- + function showFloat() { + if (document.getElementById('cfsync-float')) return; + + const wrap = document.createElement('div'); + wrap.id = 'cfsync-float'; + + const head = document.createElement('div'); + head.id = 'cfsync-float-head'; + const title = document.createElement('strong'); + title.textContent = 'CFSync — CFS Slots'; + const close = document.createElement('button'); + close.id = 'cfsync-float-close'; + close.textContent = '×'; + close.onclick = () => { wrap.remove(); clearInterval(pollTimer); pollTimer = null; }; + head.appendChild(title); + head.appendChild(close); + wrap.appendChild(head); + + const inner = document.createElement('div'); + inner.id = 'cfsync-panel'; + const hdr = document.createElement('div'); + hdr.className = 'cfsp-header'; + statusEl = document.createElement('span'); + statusEl.className = 'cfsp-status'; + statusEl.textContent = 'connecting…'; + hdr.appendChild(statusEl); + inner.appendChild(hdr); + slotsContainer = document.createElement('div'); + slotsContainer.className = 'cfsp-boxes'; + inner.appendChild(slotsContainer); + const foot = document.createElement('div'); + foot.className = 'cfsp-foot'; + foot.innerHTML = 'Open CFSync \u2197'; + inner.appendChild(foot); + wrap.appendChild(inner); + + document.body.appendChild(wrap); + injected = true; + startPolling(); + } + + // ---------- Init ---------- + function init() { + if (inject()) return; + + // Watch for DOM changes — Fluidd is a SPA, cards may not exist yet + const obs = new MutationObserver(() => { + if (inject()) obs.disconnect(); + }); + obs.observe(document.body, { childList: true, subtree: true }); + + // After 15 s give up on card replacement and show floating panel instead + setTimeout(() => { + obs.disconnect(); + if (!injected) { + console.warn('[CFSync panel] Runout Sensors card not found — showing floating panel. Make sure the card is visible on the current Fluidd page.'); + showFloat(); + } + }, 15000); + } + + // Delay slightly to let Vue/Vuetify finish rendering + setTimeout(init, 700); +})(); diff --git a/static/index.html b/static/index.html index 5e903ad..d4f1d76 100644 --- a/static/index.html +++ b/static/index.html @@ -1,78 +1,61 @@ - + - Filament Anzeige (K2 Plus / CFS) + CFSync +
- +
+ CFSync +
-
Filament Anzeige
-
© bei jkef 2026
+
CFSync
+
Connecting…
-
-
Printer: —
-
CFS: —
+
+
+ + + +
+ +
+
Printers: —
+
CFS: —
+
-
-
-
- -
-
-
Aktiv
-
-
-
- -
-
- - -
+
- Tip: Wenn Farben/Material nicht angezeigt werden, prüfe in data/config.json die moonraker_url. + + Fluidd panel + + + · + + +
- + - +
-
- - - + -
- - - +
+
Note: This saves locally in this app only (no POST to printer).
+
+
+
+ + + + + + diff --git a/static/jobs.html b/static/jobs.html new file mode 100644 index 0000000..fa5b3e2 --- /dev/null +++ b/static/jobs.html @@ -0,0 +1,81 @@ + + + + + + CFSync · Jobs + + + + +
+
+
+ CFSync +
+
+
CFSync
+
Job History
+
+
+
+ Dashboard +
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+
+
+ + Page 1 + +
+
+
+ + + + diff --git a/static/jobs.js b/static/jobs.js new file mode 100644 index 0000000..77602d7 --- /dev/null +++ b/static/jobs.js @@ -0,0 +1,230 @@ +const $ = (id) => document.getElementById(id); + +function fmtTs(ts) { + if (!ts) return "—"; + try { return new Date(ts * 1000).toLocaleString(); } catch { return "—"; } +} + +function fmtDuration(startTs, endTs) { + const start = Number(startTs || 0); + const end = Number(endTs || 0); + if (!(start > 0) || !(end >= start)) return "—"; + let secs = Math.round(end - start); + const d = Math.floor(secs / 86400); secs -= d * 86400; + const h = Math.floor(secs / 3600); secs -= h * 3600; + const m = Math.floor(secs / 60); secs -= m * 60; + if (d > 0) return `${d}d ${h}h ${m}m`; + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${secs}s`; + return `${secs}s`; +} + +function fmtG(g) { + const n = Number(g || 0); + if (n >= 100) return `${n.toFixed(0)} g`; + if (n >= 10) return `${n.toFixed(1)} g`; + return `${n.toFixed(2)} g`; +} + +function recentJobSlotLabel(slotId) { + const sid = String(slotId || "").toUpperCase(); + if (sid === "SP") return "Spool"; + if (sid === "UNKNOWN" || sid === "UNASSIGNED" || sid === "?") return "Unassigned"; + if (/^[1-4][A-D]$/.test(sid)) return `CFS Box ${sid[0]} · ${sid}`; + return sid || "—"; +} + +let currentOffset = 0; +const pageSize = 50; +let currentTotal = 0; + +function dateToTs(v, endOfDay = false) { + if (!v) return ""; + const d = new Date(`${v}T${endOfDay ? "23:59:59" : "00:00:00"}`); + const ts = Math.floor(d.getTime() / 1000); + return Number.isFinite(ts) ? String(ts) : ""; +} + +function currentFilters() { + return { + printer_id: $("fPrinter")?.value || "", + material: $("fMaterial")?.value || "", + spoolman_id: $("fSpool")?.value || "", + needs_link: $("fNeedsLink")?.value || "", + q: $("fQuery")?.value?.trim() || "", + from_ts: dateToTs($("fFrom")?.value || "", false), + to_ts: dateToTs($("fTo")?.value || "", true), + }; +} + +function buildQuery(filters, offset) { + const p = new URLSearchParams(); + for (const [k, v] of Object.entries(filters)) { + if (v !== "" && v != null) p.set(k, String(v)); + } + p.set("limit", String(pageSize)); + p.set("offset", String(Math.max(0, offset || 0))); + return p.toString(); +} + +function fillSelect(sel, values, anyLabel) { + if (!sel) return; + const current = sel.value; + sel.innerHTML = ""; + const any = document.createElement("option"); + any.value = ""; + any.textContent = anyLabel; + sel.appendChild(any); + for (const v of values || []) { + const o = document.createElement("option"); + o.value = v; + o.textContent = v; + sel.appendChild(o); + } + if ([...sel.options].some(o => o.value === current)) sel.value = current; +} + +function renderJobs(items) { + const wrap = $("jobsList"); + if (!wrap) return; + wrap.innerHTML = ""; + if (!Array.isArray(items) || !items.length) { + const empty = document.createElement("div"); + empty.className = "emptyState"; + empty.textContent = "No jobs match these filters."; + wrap.appendChild(empty); + return; + } + + for (const j of items) { + const entry = document.createElement("div"); + entry.className = "moonEntry"; + + const row = document.createElement("div"); + row.className = "moonRow"; + const left = document.createElement("div"); + left.className = "moonJob"; + left.textContent = `Printer: ${j.printer_id || "—"}${j.job_name ? " · " + j.job_name : ""}`; + const right = document.createElement("div"); + right.className = "moonNums"; + right.textContent = `${Number(j.total_meters || 0).toFixed(1)} m · ${fmtG(j.total_grams)}`; + row.appendChild(left); + row.appendChild(right); + entry.appendChild(row); + + const sub = document.createElement("div"); + sub.className = "moonSub"; + const flags = []; + if (j.source === "moonraker_history") flags.push("Recovered while offline"); + if (j.needs_link) flags.push("Needs spool link"); + const suffix = flags.length ? ` · ${flags.join(" · ")}` : ""; + sub.textContent = `Start: ${fmtTs(j.started_at)} · End: ${fmtTs(j.ended_at)} · Print Time: ${fmtDuration(j.started_at, j.ended_at)}${suffix}`; + entry.appendChild(sub); + + const spoolList = document.createElement("div"); + spoolList.className = "moonSpoolList"; + const spools = Array.isArray(j.spools) ? j.spools : []; + if (!spools.length) { + const empty = document.createElement("div"); + empty.className = "moonSpoolEmpty"; + empty.textContent = "No spool usage recorded"; + spoolList.appendChild(empty); + } else { + for (const s of spools) { + const spoolRow = document.createElement("div"); + spoolRow.className = "moonSpoolRow"; + const info = document.createElement("div"); + info.className = "moonSpoolInfo"; + + const swatch = document.createElement("span"); + swatch.className = "moonSpoolSwatch"; + const col = String(s.color_hex || "").trim(); + if (/^#[0-9a-fA-F]{6}$/.test(col)) swatch.style.background = col; + info.appendChild(swatch); + + const textWrap = document.createElement("div"); + textWrap.className = "moonSpoolTextWrap"; + const label = document.createElement("div"); + label.className = "moonSpoolLabel"; + const spoolId = Number(s.spoolman_id || 0); + const material = String(s.material || "").trim().toUpperCase(); + label.textContent = `${recentJobSlotLabel(s.slot)} · ${spoolId > 0 ? "#" + spoolId : "not linked"}${material ? " · " + material : ""}`; + const meta = document.createElement("div"); + meta.className = "moonSpoolMeta"; + meta.textContent = `${Number(s.meters || 0).toFixed(2)} m · ${fmtG(Number(s.grams || 0))}`; + textWrap.appendChild(label); + textWrap.appendChild(meta); + info.appendChild(textWrap); + spoolRow.appendChild(info); + spoolList.appendChild(spoolRow); + } + } + + entry.appendChild(spoolList); + wrap.appendChild(entry); + } +} + +async function loadJobs(resetOffset = false) { + if (resetOffset) currentOffset = 0; + const filters = currentFilters(); + const qs = buildQuery(filters, currentOffset); + const r = await fetch(`/api/ui/jobs?${qs}`, { cache: "no-store" }); + if (!r.ok) throw new Error(await r.text()); + const data = await r.json(); + + currentTotal = Number(data.total || 0); + fillSelect($("fPrinter"), (data.options || {}).printers || [], "All printers"); + fillSelect($("fMaterial"), (data.options || {}).materials || [], "All materials"); + renderJobs(data.items || []); + + const from = currentTotal === 0 ? 0 : currentOffset + 1; + const to = Math.min(currentOffset + pageSize, currentTotal); + const summary = $("jobsSummary"); + if (summary) summary.textContent = `${from}-${to} of ${currentTotal}`; + + const page = Math.floor(currentOffset / pageSize) + 1; + const pages = Math.max(1, Math.ceil(currentTotal / pageSize)); + const pageInfo = $("pageInfo"); + if (pageInfo) pageInfo.textContent = `Page ${page} / ${pages}`; + + const prev = $("prevPage"); + const next = $("nextPage"); + if (prev) prev.disabled = currentOffset <= 0; + if (next) next.disabled = (currentOffset + pageSize) >= currentTotal; +} + +function init() { + $("applyFilters")?.addEventListener("click", () => { loadJobs(true).catch(showErr); }); + $("resetFilters")?.addEventListener("click", () => { + if ($("fPrinter")) $("fPrinter").value = ""; + if ($("fMaterial")) $("fMaterial").value = ""; + if ($("fSpool")) $("fSpool").value = ""; + if ($("fNeedsLink")) $("fNeedsLink").value = ""; + if ($("fFrom")) $("fFrom").value = ""; + if ($("fTo")) $("fTo").value = ""; + if ($("fQuery")) $("fQuery").value = ""; + loadJobs(true).catch(showErr); + }); + $("prevPage")?.addEventListener("click", () => { + currentOffset = Math.max(0, currentOffset - pageSize); + loadJobs(false).catch(showErr); + }); + $("nextPage")?.addEventListener("click", () => { + currentOffset += pageSize; + loadJobs(false).catch(showErr); + }); +} + +function showErr(err) { + const wrap = $("jobsList"); + if (!wrap) return; + wrap.innerHTML = ""; + const d = document.createElement("div"); + d.className = "emptyState"; + d.textContent = `Failed to load jobs: ${err.message || String(err)}`; + wrap.appendChild(d); +} + +init(); +loadJobs(true).catch(showErr); diff --git a/static/logo.png b/static/logo.png new file mode 100644 index 0000000..3a7e6d4 Binary files /dev/null and b/static/logo.png differ diff --git a/static/style.css b/static/style.css index cc53663..79df384 100644 --- a/static/style.css +++ b/static/style.css @@ -34,8 +34,21 @@ body{ z-index:10; } +.topbarRight{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end} +.spoolmanLink{ + font-size:12px; + color:var(--muted); + text-decoration:none; + padding:6px 10px; + border-radius:999px; + border:1px solid rgba(29,42,58,.8); + background:rgba(15,34,54,.45); +} +.spoolmanLink:hover{color:var(--text);border-color:rgba(255,255,255,.2)} + .brand{display:flex;gap:12px;align-items:center} -.logo{font-size:28px} +.logoWrap{height:38px;flex-shrink:0} +.logoImg{height:38px;width:auto;object-fit:contain;} .title{font-size:18px;font-weight:700;letter-spacing:.2px} .subtitle{font-size:12px;color:var(--muted);margin-top:2px} @@ -53,6 +66,28 @@ body{ .container{max-width:1400px;margin:0 auto;padding:18px} +.printersWrap{display:flex;flex-direction:column;gap:16px} +.printerBlock{ + border:1px solid var(--line); + background: linear-gradient(180deg, rgba(17,24,36,.85), rgba(11,15,20,.85)); + border-radius:24px; + padding:14px; + box-shadow: 0 12px 28px rgba(0,0,0,.28); +} +.printerHead{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px} +.printerTitleWrap{display:flex;flex-direction:column;gap:2px;min-width:0} +.printerName{font-size:16px;font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.printerMeta{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.printerBadges{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end} +.emptyState{ + border:1px dashed rgba(255,255,255,.12); + border-radius:18px; + padding:14px 16px; + color:var(--muted); + font-size:13px; + text-align:center; +} + .layout{ display:grid; grid-template-columns: 2fr 1fr; @@ -63,13 +98,69 @@ body{ .layout{grid-template-columns: 1fr;} } -.grid{ - display:grid; - grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); - gap:14px; +.grid{display:flex;flex-direction:column;gap:10px} + +/* Creality-style box rows */ +.boxRow{display:flex;gap:8px;align-items:stretch} +.boxHeader{ + display:flex;flex-direction:column;align-items:center;justify-content:center;gap:7px; + width:74px;min-width:74px;padding:14px 8px; + border-radius:18px;border:1px solid var(--line); + background:linear-gradient(180deg,rgba(17,24,36,.85),rgba(11,15,20,.85)); +} +.boxHeaderTitle{font-size:14px;font-weight:700;color:var(--text)} +.boxEnvChip{ + font-size:11px; + color:var(--muted); + text-align:center; + border-radius:999px; + border:1px solid transparent; + line-height:1; +} +.boxEnvChipBtn{ + cursor:pointer; + padding:4px 8px; + border-color:rgba(255,255,255,.08); + background:rgba(255,255,255,.03); + transition:border-color .15s, color .15s, background .15s; +} +.boxEnvChipBtn:hover{ + border-color:rgba(255,255,255,.24); + color:var(--text); + background:rgba(255,255,255,.06); } -@media (max-width: 420px){ - .grid{grid-template-columns:1fr} +.boxSlots{display:flex;gap:8px;flex:1} +.boxSlotsSingle{flex:0 0 auto} +.boxSlotsSingle .slotPod{flex:0 0 128px} +.slotPod{ + display:flex;flex-direction:column;align-items:center;gap:6px;flex:1; + padding:12px 8px 10px;border-radius:18px; + border:1px solid var(--line); + background:linear-gradient(180deg,rgba(17,24,36,.85),rgba(11,15,20,.85)); + cursor:pointer;transition:border-color .15s,background .15s;user-select:none; +} +.slotPod:hover{border-color:rgba(255,255,255,.2);background:rgba(255,255,255,.04)} +.slotPod.active{ + border-color:rgba(31,157,85,.55); + background:linear-gradient(180deg,rgba(16,32,22,.9),rgba(11,21,15,.9)); +} +.slotPodId{ + font-size:11px;font-weight:700;color:var(--muted);letter-spacing:.5px; + border:1px solid var(--line);border-radius:999px;padding:2px 8px;line-height:1.5; +} +.slotPod.active .slotPodId{border-color:rgba(31,157,85,.5);color:var(--ok)} +.slotPodSpool{width:72px;height:72px;display:flex;align-items:center;justify-content:center} +.slotPodSpool svg{width:72px;height:72px} +.slotPodMaterial{font-size:14px;font-weight:700;color:var(--text);text-align:center;min-height:18px} +.slotPodPct{font-size:11px;color:var(--muted);text-align:center} +.slotPodLink{width:7px;height:7px;border-radius:50%;background:rgba(255,255,255,.12);margin-top:2px} +.slotPodLink.linked{background:rgba(31,157,85,.75);box-shadow:0 0 5px rgba(31,157,85,.4)} +@media(max-width:600px){ + .boxHeader{width:54px;min-width:54px;font-size:12px} + .boxSlots{gap:5px} + .slotPod{padding:10px 5px 8px} + .slotPodSpool,.slotPodSpool svg{width:56px;height:56px} + .boxSlotsSingle .slotPod{flex-basis:104px} } .card{ @@ -90,6 +181,8 @@ body{ } .cardTitle{font-size:16px;font-weight:700} +.cardExtLink{font-size:13px;font-weight:400;color:var(--muted);text-decoration:none;margin-left:4px;opacity:.6} +.cardExtLink:hover{opacity:1;color:var(--text)} .cardMeta{ display:flex; gap:10px; @@ -162,6 +255,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 +274,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; @@ -209,10 +304,35 @@ body{ font-size:12px; } -.footer{margin:18px 2px 0;color:var(--muted);font-size:12px} +.footer{margin:18px 2px 0;color:var(--muted);font-size:12px;display:flex;align-items:center;flex-wrap:wrap;gap:6px} +.footerSep{opacity:.35} +.footerBookmark{display:inline-flex;align-items:center;gap:5px} +.footerLink{color:var(--muted);text-decoration:underline;text-decoration-style:dotted;cursor:grab} +.footerLink:hover{color:var(--text)} +.footerCopyBtn{font-size:11px;padding:2px 6px;border-radius:6px;border:1px solid var(--line);background:rgba(255,255,255,.04);color:var(--muted);cursor:pointer;line-height:1.4} +.footerCopyBtn:hover{color:var(--text);border-color:rgba(255,255,255,.2)} code{font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace} .history{padding:12px 14px 14px; display:flex; flex-direction:column; gap:12px;} +.spoolStatusCard{font-size:13px} +.spoolStatusFilament{display:flex;align-items:center;gap:9px;padding-bottom:10px;margin-bottom:4px;border-bottom:1px solid rgba(255,255,255,.08)} +.spoolStatusFilamentInfo{display:flex;flex-direction:column;gap:2px;min-width:0} +.spoolStatusFilamentName{font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.spoolStatusFilamentSub{font-size:12px;color:var(--muted)} +.spoolStatRow{display:flex;justify-content:space-between;align-items:baseline;padding:5px 0;border-bottom:1px solid rgba(255,255,255,.05)} +.spoolStatRow:last-child{border-bottom:none} +.spoolStatLabel{font-size:12px;color:var(--muted)} +.spoolStatValue{font-size:13px;font-weight:500;color:var(--text);text-align:right} + +.cfsBox{padding:6px 0;border-bottom:1px solid rgba(255,255,255,.06)} +.cfsBox:last-child{border-bottom:none} +.cfsBoxHead{display:flex;justify-content:space-between;align-items:baseline;padding:3px 0 4px;font-size:12px;font-weight:700;color:var(--text)} +.cfsBoxTotals{font-size:11px;font-weight:500;color:var(--muted)} +.cfsSlotRow{display:grid;grid-template-columns:32px 1fr 1fr 56px;gap:4px;align-items:baseline;padding:2px 0;font-size:12px;color:var(--text)} +.cfsSlotLabel{font-size:11px;font-weight:700;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace} +.cfsSlotMeters{color:var(--text)} +.cfsSlotKg{color:var(--muted)} +.cfsSlotLast{font-size:11px;color:var(--muted);text-align:right;white-space:nowrap} .moonHist{display:flex; flex-direction:column; gap:10px; margin-top:10px;} .moonEntry{border:1px solid rgba(255,255,255,0.06); background:rgba(20,28,40,0.55); border-radius:14px; padding:10px 12px;} @@ -220,6 +340,30 @@ code{font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospa .moonJob{font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;} .moonNums{font-weight:700; color:rgba(255,255,255,0.92); white-space:nowrap;} .moonSub{margin-top:6px; display:flex; flex-wrap:wrap; gap:10px; font-size:12px; color:rgba(255,255,255,0.72);} +.moonSpoolList{margin-top:8px;display:flex;flex-direction:column;gap:7px;} +.moonSpoolRow{ + display:flex; + align-items:center; + justify-content:space-between; + gap:10px; + border:1px solid rgba(255,255,255,0.06); + background:rgba(10,16,24,0.45); + border-radius:12px; + padding:7px 9px; +} +.moonSpoolInfo{display:flex;align-items:center;gap:8px;min-width:0;} +.moonSpoolSwatch{ + width:14px; + height:14px; + border-radius:4px; + border:1px solid rgba(255,255,255,.20); + background:rgba(255,255,255,.10); + flex-shrink:0; +} +.moonSpoolTextWrap{min-width:0;display:flex;flex-direction:column;gap:1px;} +.moonSpoolLabel{font-size:12px;font-weight:600;color:rgba(255,255,255,0.9);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} +.moonSpoolMeta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} +.moonSpoolEmpty{font-size:12px;color:var(--muted);} .miniPills{display:flex; flex-wrap:wrap; gap:6px;} .miniPill{border:1px solid rgba(255,255,255,0.08); background:rgba(10,16,24,0.55); border-radius:999px; padding:3px 8px; font-size:12px; color:rgba(255,255,255,0.82);} @@ -272,3 +416,108 @@ code{font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospa .modalStats{margin-top:8px;padding:10px 12px;border-radius:12px;border:1px solid rgba(255,255,255,.08);background:rgba(0,0,0,.18);font-size:13px;line-height:1.45} .modalHint{margin-top:10px;font-size:12px;color:var(--muted)} @media (max-width:560px){.formRow{grid-template-columns:1fr}} + +.envModalPanel{width:min(920px,calc(100% - 32px))} +.envChartMeta{ + font-size:12px; + color:var(--muted); + margin-bottom:10px; +} +.envChartRanges{ + display:flex; + gap:8px; + margin-bottom:10px; +} +.envRangeBtn{ + font-size:12px; + color:var(--muted); + border:1px solid rgba(255,255,255,.10); + background:rgba(255,255,255,.04); + border-radius:999px; + padding:5px 10px; + cursor:pointer; + transition:border-color .15s, color .15s, background .15s; +} +.envRangeBtn:hover{ + color:var(--text); + border-color:rgba(255,255,255,.25); +} +.envRangeBtn.active{ + color:var(--text); + border-color:rgba(63,182,255,.6); + background:rgba(63,182,255,.16); +} +.envChartBody{ + border:1px solid rgba(255,255,255,.08); + border-radius:12px; + background:rgba(0,0,0,.18); + min-height:320px; + padding:10px; +} +.envChartEmpty{ + min-height:298px; + display:flex; + align-items:center; + justify-content:center; + color:var(--muted); + font-size:13px; +} +.envChartSvg{display:block;width:100%;height:auto} +.envGridLine{stroke:rgba(255,255,255,.08);stroke-width:1} +.envAxisText{fill:rgba(255,255,255,.56);font-size:11px} +.envLineTemp{stroke:#ff8a3d;fill:none;stroke-width:2.5} +.envLineHum{stroke:#3fb6ff;fill:none;stroke-width:2.5} +.envAreaTemp{fill:rgba(255,138,61,.15)} +.envAreaHum{fill:rgba(63,182,255,.14)} +.envPoint{fill:#ffffff;stroke-width:2} + +/* --- Spoolman section in modal --- */ +.spoolmanSection{margin-bottom:14px;padding:10px 12px;border-radius:12px;border:1px solid rgba(255,255,255,.08);background:rgba(0,0,0,.18)} +.spoolmanHead{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px} +.spoolmanTitle{font-size:13px;font-weight:700} +.spoolmanRow{display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.spoolmanNotLinkedWrap{display:flex;flex-direction:column;gap:8px} +.spoolmanFilters{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.spoolmanFilterSel{ + width:100%; + min-width:0; + padding:8px 10px; + border-radius:10px; + border:1px solid rgba(255,255,255,.12); + background:rgba(0,0,0,.25); + color:var(--text); + font-size:12px; +} +.spoolmanFilterSel:focus{outline:none;border-color:rgba(255,255,255,.28)} +.spoolmanList{max-height:180px;overflow-y:auto;border-radius:10px;border:1px solid rgba(255,255,255,.12);background:rgba(0,0,0,.25)} +.spoolmanListItem{display:flex;align-items:center;gap:8px;padding:7px 10px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,.05);font-size:13px;transition:background .12s;color:var(--text)} +.spoolmanListItem:last-child{border-bottom:none} +.spoolmanListItem:hover{background:rgba(255,255,255,.07)} +.spoolmanListItem.selected{background:rgba(31,157,85,.18)} +.spoolmanListItem.muted{cursor:default;color:var(--muted)} +.spoolmanListSwatch{width:13px;height:13px;border-radius:3px;flex-shrink:0;border:1px solid rgba(255,255,255,.18)} +.spoolmanInfo{font-size:13px;color:var(--muted);flex:1} +.spoolmanActions{display:flex;gap:8px} +@media (max-width:560px){.spoolmanFilters{grid-template-columns:1fr}} + +/* --- Jobs page --- */ +.jobsPage{display:flex;flex-direction:column;gap:14px} +.jobsFiltersCard,.jobsListCard{padding:14px} +.jobsFilterGrid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px} +.jobsField{display:flex;flex-direction:column;gap:5px;min-width:0} +.jobsField label{font-size:12px;color:var(--muted)} +.jobsField input,.jobsField select{ + width:100%; + min-width:0; + padding:9px 10px; + border-radius:10px; + border:1px solid rgba(255,255,255,.12); + background:rgba(0,0,0,.22); + color:var(--text); +} +.jobsFieldWide{grid-column:span 2} +.jobsFilterActions{margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap} +.jobsSummary{font-size:12px;color:var(--muted)} +.jobsPager{margin-top:10px;display:flex;justify-content:center;align-items:center;gap:10px} +@media (max-width:980px){.jobsFilterGrid{grid-template-columns:repeat(2,minmax(0,1fr))}.jobsFieldWide{grid-column:span 2}} +@media (max-width:560px){.jobsFilterGrid{grid-template-columns:1fr}.jobsFieldWide{grid-column:span 1}} diff --git a/update.sh b/update.sh index 9e9fd3f..1f9e0a4 100644 --- a/update.sh +++ b/update.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/davidkinnes/CFSync.git" if [[ ${EUID} -ne 0 ]]; then echo "Please run with sudo" @@ -16,14 +16,15 @@ if [[ -z "$REAL_USER" || "$REAL_USER" == "root" ]]; then exit 1 fi -echo "Updating Filament Management..." +echo "Updating CFSync..." rm -rf /tmp/filament-update git clone --depth 1 "$REPO_URL" /tmp/filament-update -rsync -a --delete \ +rsync -a \ --exclude ".git/" \ --exclude "data/" \ + --exclude "venv/" \ --exclude "__pycache__/" \ /tmp/filament-update/ "$APP_DIR/" @@ -31,6 +32,10 @@ rm -rf /tmp/filament-update sudo -u "$REAL_USER" bash -lc " cd '$APP_DIR' +if [[ ! -f venv/bin/activate ]]; then + echo 'Recreating virtual environment...' + python3 -m venv venv +fi source venv/bin/activate pip install -r requirements.txt " diff --git a/ws_dump.py b/ws_dump.py new file mode 100644 index 0000000..55a808b --- /dev/null +++ b/ws_dump.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +ws_dump.py — Creality K2 Plus WebSocket raw message dumper + +Connects to ws://:9999, sends only heartbeats to keep the +connection alive, and dumps EVERY message the printer pushes to +stdout and an optional JSONL log file. + +The printer streams data continuously — temperatures, status, CFS +slot events — without needing to be polled for most of it. This +script captures everything as-is so you can analyse the full protocol. + +Usage: + python3 ws_dump.py [output-file] + +Example: + python3 ws_dump.py 192.168.1.144 + python3 ws_dump.py 192.168.1.144 capture.jsonl + +Requirements: + pip install websockets (same venv as the main app) + +What to look for: + - "rfid" fields inside materialBoxs → unique spool RFID ID + - "materialState" → fires on spool insert/remove/scan + - Any top-level key you haven't seen before (marked with ***) + - Insert a spool while running and watch what bursts come through +""" + +import asyncio +import json +import sys +import time +from datetime import datetime + +try: + import websockets +except ImportError: + print("ERROR: websockets not installed. Run: pip install websockets") + sys.exit(1) + +HEARTBEAT_INTERVAL = 10.0 # seconds between heartbeats +RECV_TIMEOUT = 15.0 # seconds of silence before sending a keepalive + +HEARTBEAT_REQ = json.dumps({"ModeCode": "heart_beat"}) + +seen_keys: set[str] = set() + + +def ts() -> str: + return datetime.now().strftime("%H:%M:%S.%f")[:-3] + + +def pretty(obj) -> str: + return json.dumps(obj, indent=2, ensure_ascii=False) + + +def annotate(raw: str) -> list[str]: + """Return human-readable notes about interesting fields in a message.""" + notes = [] + try: + d = json.loads(raw) + except Exception: + return notes + + # Track new top-level keys + global seen_keys + new = set(d.keys()) - seen_keys + if new: + seen_keys |= new + notes.append(f" *** NEW TOP-LEVEL KEYS: {sorted(new)}") + + # RFID fields inside boxsInfo + boxes = (d.get("boxsInfo") or {}).get("materialBoxs") or [] + for box in boxes: + for mat in (box.get("materials") or []): + rfid = mat.get("rfid", "") + state = mat.get("state", 0) + slot_letter = "ABCD"[mat["id"]] if isinstance(mat.get("id"), int) and 0 <= mat["id"] <= 3 else "?" + slot = f"{box.get('id', '?')}{slot_letter}" + if rfid: + notes.append( + f" >>> RFID slot {slot}: {rfid!r} state={state} " + f"({'RFID chip' if state == 2 else 'manual' if state == 1 else 'empty'})" + ) + elif state > 0: + notes.append(f" --- slot {slot}: no rfid state={state} material={mat.get('type','?')}") + + # materialState — fires on spool events + if "materialState" in d: + notes.append(f" >>> materialState: {d['materialState']}") + + # deviceState / state — printer status + if "deviceState" in d: + notes.append(f" >>> deviceState: {d['deviceState']}") + if "state" in d: + notes.append(f" >>> state: {d['state']}") + + return notes + + +async def dump(host: str, out_path: str | None) -> None: + url = f"ws://{host}:9999" + print(f"[{ts()}] Connecting to {url} ...") + + out_file = None + if out_path: + out_file = open(out_path, "a", encoding="utf-8") + print(f"[{ts()}] Logging raw messages to {out_path}") + + def log(raw: str, direction: str = "RECV") -> None: + if out_file: + out_file.write(json.dumps({ + "t": time.time(), + "ts": ts(), + "dir": direction, + "raw": raw, + }) + "\n") + out_file.flush() + + async with websockets.connect( + url, + ping_interval=None, + ping_timeout=None, + close_timeout=5, + max_size=2**22, + ) as ws: + print(f"[{ts()}] Connected — listening passively (heartbeat only)\n") + print(" Insert / remove a spool and watch for events marked >>>") + print(" Newly seen message types are marked ***") + print(" Press Ctrl+C to stop\n") + + last_heartbeat = time.time() + + async def heartbeat_loop() -> None: + nonlocal last_heartbeat + while True: + await asyncio.sleep(HEARTBEAT_INTERVAL) + await ws.send(HEARTBEAT_REQ) + log(HEARTBEAT_REQ, "SEND") + last_heartbeat = time.time() + + asyncio.create_task(heartbeat_loop()) + + async for raw in ws: + log(raw) + + if raw == "ok": + print(f"[{ts()}] heartbeat ack") + continue + + try: + parsed = json.loads(raw) + except Exception: + print(f"[{ts()}] non-JSON: {raw[:300]}") + continue + + top_keys = list(parsed.keys()) + print(f"[{ts()}] keys={top_keys}") + + for note in annotate(raw): + print(note) + + # Full dump for boxsInfo (richest payload) + if "boxsInfo" in parsed: + print(pretty(parsed)) + print() + + +def main() -> None: + if len(sys.argv) < 2: + print(f"Usage: python3 {sys.argv[0]} [output.jsonl]") + sys.exit(1) + + host = sys.argv[1] + out = sys.argv[2] if len(sys.argv) > 2 else None + + try: + asyncio.run(dump(host, out)) + except KeyboardInterrupt: + print("\nStopped.") + except Exception as e: + print(f"Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main()