diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..a29c2d3 --- /dev/null +++ b/.env.template @@ -0,0 +1,5 @@ +PRINTER_URLS=["127.0.0.1"] +FILAMENT_DIAMETER=1.75 +SPOOLMAN_URL=http://127.0.0.1:7912 +SPOOLMAN_MODE=direct + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c54f60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +data/ +.env diff --git a/CLAUDE.md b/CLAUDE.md index 611eb8f..01525c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Filament-Management is a local web application for tracking 3D printer filament/spool usage, built for Creality K2 Plus CFS (4x4 slot grid) and Klipper/Moonraker-based printers. It runs as a FastAPI backend with a vanilla JavaScript SPA frontend. The UI supports German and English via `static/i18n.js` (auto-detects browser language, persists choice in localStorage). +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 @@ -21,22 +21,58 @@ uvicorn main:app --reload --host 0.0.0.0 --port 8000 curl http://localhost:8000/api/health ``` +### Mock printer server (no real hardware needed) + +`mock_printer.py` simulates one or more K2 Plus printers (WebSocket CFS server on port 9999 + Moonraker HTTP on port 7125). Run multiple instances on different loopback IPs to test multi-printer setup: + +```bash +# Terminal 1 +python3 mock_printer.py --host 127.0.0.2 --name "Printer Alpha" +# Terminal 2 +python3 mock_printer.py --host 127.0.0.3 --name "Printer Beta" +``` + +Then set `"printer_urls": ["127.0.0.2", "127.0.0.3"]` in `data/config.json`. + +Control API (same port 7125): +```bash +curl http://127.0.0.2:7125/mock/state +curl -X POST http://127.0.0.2:7125/mock/print/start +curl -X POST http://127.0.0.2:7125/mock/print/complete +curl -X POST http://127.0.0.2:7125/mock/slot/1A \ + -H 'Content-Type: application/json' \ + -d '{"state": 2, "type": "PLA", "color": "#FF0000", "name": "Red PLA", "vendor": "Bambu"}' +``` + +SSH auto-linking can also be tested without a real SSH server using `mock_sshpass.sh`. Copy it to `sshpass` and put it on PATH before starting CFSync: + +```bash +cp mock_sshpass.sh sshpass && chmod +x sshpass +PATH="$PWD:$PATH" uvicorn main:app --reload --host 0.0.0.0 --port 8000 +# Set rfid to a plain integer matching a Spoolman spool ID to trigger auto-link: +curl -X POST http://127.0.0.2:7125/mock/slot/1A \ + -H 'Content-Type: application/json' \ + -d '{"state": 2, "rfid": "42"}' +``` + 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. +**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. -**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). +**Multi-printer support:** Multiple printers are stored as separate entries under `state["printers"][printer_id]`. Each printer runs its own independent WebSocket loop and Moonraker poll loop. Config accepts `printer_urls` (array of IPs/hostnames) or the legacy `printer_url` (single), both are migrated into the same internal `printers` list at startup. ## 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). +- **Slot IDs:** Literal type `SlotId` = `"1A"` through `"4D"` (4 boxes × 4 colors, 16 total) plus `"SP"` for the printer's direct spool holder (box type=1 in the WebSocket protocol). - **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`). @@ -44,7 +80,15 @@ There are no automated tests, linting tools, or CI/CD pipelines configured. ## Spoolman Integration (Optional) -Set `spoolman_url` in `data/config.json` to enable. This app acts as the only bridge between Spoolman and the printer (Moonraker's Spoolman plugin is not used). Spools are linked manually via the slot modal dropdown. On link, `remaining_weight` is imported from Spoolman. Consumption is synced back via `PUT /api/v1/spool/{id}/use` (fire-and-forget) when prints finalize or manual allocations are made. Roll changes auto-unlink the Spoolman spool. All Spoolman calls are best-effort and never block local tracking. +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 via SSH serialNum lookup (see below). 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. + +**Auto-linking (SSH serialNum):** When a slot transitions to RFID state (CFS state=2), `_ssh_fetch_and_apply()` is triggered (at most every 30s per printer). It SSHes into the printer using `sshpass` + system `ssh`, tries multiple passwords (`creality_2023`, `creality_2024`, `creality`) and two file paths (stock firmware: `/usr/data/creality/userdata/box/material_box_info.json`; K2-Improvements: `/mnt/UDISK/creality/userdata/box/material_box_info.json`). Reads each slot's `serialNum` field; if it's a valid integer matching a Spoolman spool ID, that slot is auto-linked. Requires `sshpass` installed and SSH access to the printer. + +**Auto-unlinking:** Spoolman is auto-unlinked when: (a) a slot transitions from RFID to any other state, (b) any loaded slot goes to empty, or (c) a loaded slot's material/name/vendor/color fingerprint changes (catches manual spool swaps). + +**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:** Remaining % is calculated the same way for all linked slots — using Spoolman's `remaining_weight` divided by the spool's initial weight (`filament.weight`). Cached per-slot with a 60s TTL (`_SPOOLMAN_PCT_TTL`). ## Production Deployment diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7766826 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM python:3.12-slim AS builder + +# Set working directory +WORKDIR /app + +COPY . . + +# Install dependencies to a specific folder +RUN pip install --no-cache-dir --prefix=/install -r requirements.txt + +FROM python:3.12-slim + +# Install packages +RUN apt-get update && apt-get install -y sshpass && rm -rf /var/lib/apt/lists/* + +# Set environment variables +ENV APP_DIR=/opt/filament-management \ + PYTHONPATH=/opt/filament-management \ + UI_PORT=8005 + +WORKDIR $APP_DIR + +# Copy Python libs and app code +COPY --from=builder /install /usr/local +COPY --from=builder /app $APP_DIR + +# Setup Entrypoint +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Expose the UI port +EXPOSE $UI_PORT + +ENTRYPOINT ["/entrypoint.sh"] + +# Start the application +CMD uvicorn main:app --host 0.0.0.0 --port $UI_PORT + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bd73be1 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +up: + docker-compose up -d +down: + docker-compose down +restart: + docker-compose down && docker-compose up -d +logs: + docker-compose logs -f +pull: + docker-compose pull +build: + docker-compose build diff --git a/README.md b/README.md index e836858..d5a27ac 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,209 @@ -# 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 the printer 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 ---- +- 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 +- **Two Spoolman sync modes** — direct reporting or Moonraker plugin (`SET_ACTIVE_SPOOL`) +- Serial-number-based auto-linking via SSH (CFTag-written spools link instantly on insert) +- Moonraker job tracking — attributes `filament_used` proportionally across active slots at print completion +- **Multi-printer support** — manage multiple printers from a single CFSync instance +- **Job history** — per-printer log of recent print jobs with filament usage breakdown +- **Spool reallocation** — manually assign usage to a specific slot after a print +- **SP slot** — tracks filament loaded directly into the printer (outside the CFS) +- Printer name and firmware version shown in header (read from WebSocket) +- Dark UI, no build step, runs as a systemd service -## 🚀 Installation (One-Liner) +## 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 IP | `192.168.1.144` | +| Spoolman URL *(optional)* | `http://192.168.1.10:7912` | + +After install, open `http://:` in your browser. + +## Configuration + +All settings are available through the **cogwheel (⚙) modal** in the UI. They are stored in `data/config.json`: + +```json +{ + "printer_urls": ["192.168.1.144"], + "filament_diameter_mm": 1.75, + "spoolman_url": "http://192.168.1.10:7912", + "spoolman_mode": "direct" +} +``` + +The legacy `"printer_url"` (single string) is still accepted and migrated automatically. + +### Multiple printers + +Add all printer IPs to the `printer_urls` array: + +```json +{ + "printer_urls": ["192.168.1.144", "192.168.1.145"] +} +``` + +Each printer gets its own state, job history, and Spoolman spool links. The UI shows a printer selector when more than one printer is configured. + +## Spoolman integration + +### Sync modes + +CFSync supports two ways to report filament consumption to Spoolman. Set the mode in the settings modal (⚙). + +**Direct mode** *(default)* + +CFSync calls `PUT /api/v1/spool/{id}/use` directly on Spoolman when a print finishes or a manual allocation is made. No Moonraker configuration needed. + +**Moonraker plugin mode** + +CFSync calls the `SET_ACTIVE_SPOOL` / `CLEAR_ACTIVE_SPOOL` G-code macros via Moonraker. Moonraker's built-in Spoolman plugin then tracks consumption itself. This requires the `[spoolman]` section in `moonraker.conf` — see setup below. + +### Moonraker plugin setup + +1. **Configure Moonraker** — add to `moonraker.conf`: + +```ini +[spoolman] +server: http://192.168.1.10:7912 +``` + +2. **Restart Moonraker** after saving. + +3. **In CFSync settings (⚙)**, set Spoolman URL to the same address and switch the sync mode to **Moonraker plugin**. + +> Moonraker must be reachable from the CFSync host. CFSync uses the printer IP (port 7125) for Moonraker API calls. If your setup uses a different Moonraker URL you can configure it separately in `data/config.json` under `moonraker_url`. + +> **Trusted clients:** CFSync's host IP must be in Moonraker's `trusted_clients` list. The default Moonraker config already includes `192.168.0.0/16` and `10.0.0.0/8`, so most LAN setups work without changes. + +### Auto-linking via serial number (SSH) + +When an RFID-tagged spool is inserted (CFS slot transitions to RFID state), CFSync SSHes into the printer and reads `material_box_info.json` to extract each slot's `serialNum` field. If the serial number is a valid integer that matches a Spoolman spool ID, the slot is linked immediately — no manual action required. + +**Why SSH and not the RFID material code?** +Creality uses shared RFID material-type codes — every spool of the same filament type carries the same code (e.g. all Creality Hyper PLA White rolls share one code). This makes RFID codes unsuitable for identifying individual spools in Spoolman. The `serialNum` read via SSH is written to the physical RFID chip and can be made unique per spool using **[CFTag](https://github.com/koen01/cftag)**. + +**SSH requirements:** +- `sshpass` must be installed on the CFSync host (`apt install sshpass`) +- The printer must be reachable over SSH (default on Creality firmware, no extra config needed) +- CFSync tries the standard Creality passwords automatically + +> Spools without a CFTag-written serial number (e.g. plain Creality stock spools) will not auto-link and must be linked manually via the slot modal. + +**Auto-unlink** happens automatically when: +- A spool is removed from a slot +- The RFID value changes on a loaded slot (different spool inserted) +- The filament material, name, or colour changes while the slot is loaded + +## Workflow — adding a new spool with RFID + +1. **Open CFTag** on Android → fill in filament details → tap **Create in Spoolman**. CFTag creates the spool entry in Spoolman and immediately prompts you to write the RFID tags. Hold your phone to the tag on each side of the spool — done in one flow. +2. **Load the spool** into a CFS slot. +3. **CFSync detects the insert**, SSHes into the printer, reads the serial number from the chip, and links the slot to the Spoolman spool automatically. + +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. + +## Fluidd panel + +CFSync can inject a spool status panel into the **Fluidd** web UI. The panel script and setup instructions are available in the settings modal (⚙) under **Fluidd panel**. + +Two delivery methods are supported: + +- **Bookmarklet** — paste a one-liner into your browser's bookmarks bar and click it to activate +- **Tampermonkey** — install the userscript for automatic injection on every page load + +## Development & testing + +CFSync includes a mock printer server for local development without real hardware. + +### Mock server + +`mock_printer.py` simulates a K2 Plus — it runs a WebSocket CFS server (port 9999) and a Moonraker HTTP API (port 7125). Run multiple instances on different loopback IPs to test the multi-printer setup: + +```bash +# In separate terminals: +python3 mock_printer.py --host 127.0.0.2 --name "Printer Alpha" +python3 mock_printer.py --host 127.0.0.3 --name "Printer Beta" +``` + +Then set `"printer_urls": ["127.0.0.2", "127.0.0.3"]` in `data/config.json` and start CFSync normally. All `127.x.x.x` addresses work as loopback aliases on Linux without extra configuration. + +Each mock exposes a control API for triggering state changes: + +```bash +# Simulate a print job lifecycle +curl -X POST http://127.0.0.2:7125/mock/print/start +curl -X POST http://127.0.0.2:7125/mock/print/complete + +# Change a CFS slot (state 2 = RFID) +curl -X POST http://127.0.0.2:7125/mock/slot/1A \ + -H 'Content-Type: application/json' \ + -d '{"state": 2, "type": "PLA", "color": "#FF5733", "name": "Orange PLA", "vendor": "Bambu"}' + +# Empty a slot +curl -X POST http://127.0.0.2:7125/mock/slot/2C/empty + +# Switch active slot +curl -X POST http://127.0.0.2:7125/mock/slot/1B/select + +# View full mock state +curl http://127.0.0.2:7125/mock/state +``` + +### Mocking SSH auto-linking + +SSH auto-linking can be tested without a real SSH server using `mock_sshpass.sh`. It intercepts the `sshpass` call and fetches `material_box_info.json` from the mock's HTTP endpoint instead: + +```bash +cp mock_sshpass.sh sshpass && chmod +x sshpass +PATH="$PWD:$PATH" uvicorn main:app --reload --host 0.0.0.0 --port 8000 +``` + +To trigger auto-linking, set a slot's RFID to a plain integer matching a Spoolman spool ID: + +```bash +curl -X POST http://127.0.0.2:7125/mock/slot/1A \ + -H 'Content-Type: application/json' \ + -d '{"state": 2, "rfid": "42"}' # auto-links to Spoolman spool #42 +``` + +## 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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7837dc9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + cfsync: + build: . + container_name: cfsync + restart: unless-stopped + ports: + - "8005:8005" + # Create .env file here and override the settings below (See .env.template for example) + environment: + - UI_PORT=8005 + - TZ=${TZ:-Asia/Jerusalem} + - PRINTER_URLS=${PRINTER_URLS:-["127.0.0.1"]} + - FILAMENT_DIAMETER=${FILAMENT_DIAMETER:-1.75} + - SPOOLMAN_URL=${SPOOLMAN_URL:-http://127.0.0.1:7912} + - SPOOLMAN_MODE=${SPOOLMAN_MODE:-direct} # direct or moonraker + volumes: + # This ensures your filament data survives container updates + - ./data:/opt/filament-management/data + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8005/api/health')"] + interval: 30s + timeout: 10s + retries: 3 diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000..6a58f22 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/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..571734d --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,32 @@ +#!/bin/sh +set -e + +CONFIG_PATH="$APP_DIR/data/config.json" + +# Only generate the file if it does not exist +if [ ! -f "$CONFIG_PATH" ]; then + echo "Config not found. Generating from environment variables..." + + # Ensure the directory exists (important for mounts) + mkdir -p "$(dirname "$CONFIG_PATH")" + + python3 -c " +import os, json + +config = { + \"printer_urls\": json.loads(os.getenv('PRINTER_URLS', '[]')), + \"filament_diameter_mm\": float(os.getenv('FILAMENT_DIAMETER', '1.75')), + \"spoolman_url\": os.getenv('SPOOLMAN_URL', ''), + \"spoolman_mode\": os.getenv('SPOOLMAN_MODE', 'remote') +} + +with open('$CONFIG_PATH', 'w') as f: + json.dump(config, f, indent=4) +" +else + echo "Config file already exists in volume, skipping generation." +fi + +# Hand off to the CMD (uvicorn) +exec "$@" + diff --git a/install.sh b/install.sh index 9ac03bc..e3d90ca 100644 --- a/install.sh +++ b/install.sh @@ -3,7 +3,7 @@ set -euo pipefail APP_DIR="/opt/filament-management" SERVICE_NAME="filament-management" -REPO_URL="https://github.com/jkef80/Filament-Management.git" +REPO_URL="https://github.com/koen01/CFSync.git" if [[ ${EUID} -ne 0 ]]; then echo "Please run with sudo" @@ -28,23 +28,29 @@ 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_IP=$(ask "Printer IP" "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 +SPOOLMAN_MODE="direct" +if [[ -n "$SPOOLMAN_URL" ]]; then + echo "Spoolman sync mode:" + echo " direct — CFSync reports usage directly to Spoolman on print end" + echo " moonraker — CFSync tells Moonraker's Spoolman plugin which spool is active" + SPOOLMAN_MODE=$(ask "Spoolman mode (direct/moonraker)" "direct") + if [[ "$SPOOLMAN_MODE" != "direct" && "$SPOOLMAN_MODE" != "moonraker" ]]; then + echo "Invalid mode, defaulting to 'direct'" + SPOOLMAN_MODE="direct" + fi +fi echo "Installing to $APP_DIR" apt-get update -y -apt-get install -y python3 python3-venv python3-pip git rsync curl +apt-get install -y python3 python3-venv python3-pip git rsync curl sshpass mkdir -p "$APP_DIR" @@ -74,11 +80,10 @@ mkdir -p "$APP_DIR/data" cat > "$APP_DIR/data/config.json" < "/etc/systemd/system/${SERVICE_NAME}.service" < dict: "3A", "3B", "3C", "3D", "4A", "4B", "4C", "4D", ] +PRINTER_SPOOL_SLOT = "SP" + +# CFS box environment sampling: keep a rolling 24h history (1 sample/min baseline) +_CFS_ENV_MIN_SAMPLE_INTERVAL = 60.0 +_CFS_ENV_MAX_POINTS = 24 * 60 +_CFS_ENV_TEMP_DELTA = 0.2 +_CFS_ENV_HUMIDITY_DELTA = 1.0 def _now() -> float: @@ -94,6 +104,35 @@ 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 _ensure_data_files() -> None: DATA_DIR.mkdir(parents=True, exist_ok=True) STATIC_DIR.mkdir(parents=True, exist_ok=True) @@ -120,17 +159,17 @@ 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": "", + # Spoolman sync mode: "direct" (CFSync PUTs usage) or + # "moonraker" (CFSync calls SET_ACTIVE_SPOOL macro, plugin tracks usage) + "spoolman_mode": "direct", }, indent=2, ensure_ascii=False, @@ -138,31 +177,8 @@ 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)) @@ -176,22 +192,149 @@ 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, - "spoolman_url": "", - } + 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", "") + cfg.setdefault("spoolman_mode", "direct") + 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 @@ -213,13 +356,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): @@ -237,12 +373,6 @@ def _migrate_state_dict(data: dict) -> dict: mat = sd.get("material") if isinstance(mat, str) and mat.strip() in ("", "-", "—", "–"): sd["material"] = "OTHER" - # allow 'remaining_g' as int - if "remaining_g" in sd and sd["remaining_g"] is not None: - try: - sd["remaining_g"] = float(sd["remaining_g"]) - except Exception: - sd["remaining_g"] = None # Spoolman integration (optional) sd.setdefault("spoolman_id", None) slots[slot_id] = sd @@ -260,9 +390,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) @@ -272,49 +410,187 @@ 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", {}) + data.setdefault("ws_slot_length_m", {}) + data.setdefault("cfs_stats", {}) + 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 = [] + for item in raw_samples: + coerced = _coerce_cfs_env_sample(item) + if not coerced: + continue + samples_out.append(coerced) + if len(samples_out) > _CFS_ENV_MAX_POINTS: + samples_out = samples_out[-_CFS_ENV_MAX_POINTS:] + if samples_out: + env_hist_out[box_id] = samples_out + data["cfs_env_history"] = env_hist_out + data.setdefault("job_history", []) + + # Clear the stale "2A" schema default — active_slot is now driven by WS only + if data.get("active_slot") == "2A": + data["active_slot"] = None - # --- 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", "") + return data - # Moonraker history snapshot - data.setdefault("moonraker_history", []) - data.setdefault("moonraker_allocations", {}) - return data +# Keep _migrate_state_dict as an alias for backward compat (used in older code paths) +_migrate_state_dict = _migrate_app_state_dict -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 + + +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 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() - try: - te = float(ts_end) if ts_end is not None else 0.0 - except Exception: - te = 0.0 - return f"{j}:{te:.0f}" +def save_state_all(state: MultiAppState) -> None: + if _state_load_failed: + print("[STATE] save skipped: last load returned fallback default") + return + state.updated_at = _now() + data = json.dumps(_model_dump(state), indent=2, ensure_ascii=False) + tmp = STATE_PATH.with_suffix(".tmp") + tmp.write_text(data) + tmp.rename(STATE_PATH) # atomic on Linux (same filesystem) + + +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) --- @@ -347,97 +623,6 @@ def mm_to_g(material: str, mm: float) -> float: return float(max(0.0, g)) -def _apply_job_usage(state: AppState, job_name: str, total_used_mm: int, slot_override: Optional[str] = None) -> None: - """Update job counters. - - Note: We intentionally do NOT decrement remaining_g here anymore. - Creality K2's CFS can change slots mid-print (multi-color). Accurate - remaining deduction is handled by the per-slot tracker finalized at - print end. - """ - total_used_mm = int(max(0, total_used_mm)) - - # Decide which slot to account against - slot_id = slot_override or state.last_accounted_slot or state.active_slot - - # If job name changed, reset delta baseline - if job_name != (state.current_job or ""): - state.last_accounted_job_mm = 0 - - delta_mm = max(0, total_used_mm - int(state.last_accounted_job_mm or 0)) - - material = state.slots[slot_id].material - - # Update state - state.current_job = job_name - state.current_job_filament_mm = total_used_mm - state.current_job_filament_g = mm_to_g(material, float(total_used_mm)) - state.last_accounted_job_mm = total_used_mm - state.last_accounted_slot = slot_id - - -def _hist_push(state: AppState, slot_id: str, entry: dict, keep: int = 50) -> None: - """Append a history entry for a slot (newest first).""" - try: - # Tag entries with the current spool epoch so UI can hide old-roll prints - try: - entry.setdefault("epoch", int(getattr(state.slots.get(slot_id), "spool_epoch", 0) or 0)) - except Exception: - entry.setdefault("epoch", 0) - h = state.slot_history.get(slot_id) - if not isinstance(h, list): - h = [] - h.insert(0, entry) - state.slot_history[slot_id] = h[:keep] - except Exception: - # never fail the poll loop due to history - pass - - -def _hist_upsert_by_src(state: AppState, slot_id: str, src: str, entry: dict, keep: int = 50) -> None: - """Insert or replace a history entry identified by a stable _src marker. - - Used to show a "live" (in-progress) entry per slot during printing without - spamming the history list. - """ - try: - if not src: - _hist_push(state, slot_id, entry, keep=keep) - return - - entry["_src"] = src - - # Tag entries with the current spool epoch so UI can hide old-roll prints - try: - entry.setdefault("epoch", int(getattr(state.slots.get(slot_id), "spool_epoch", 0) or 0)) - except Exception: - entry.setdefault("epoch", 0) - - h = state.slot_history.get(slot_id) - if not isinstance(h, list): - h = [] - - # Drop existing entries with same source marker - h = [e for e in h if not (isinstance(e, dict) and e.get("_src") == src)] - h.insert(0, entry) - state.slot_history[slot_id] = h[:keep] - except Exception: - # never fail the poll loop due to history - pass - - -def _inc_slot_epoch_consumed(state: AppState, slot_id: str, delta_g: float) -> None: - """Increment the running consumed-total for the current spool epoch.""" - try: - s = state.slots.get(slot_id) - if not s: - return - s.spool_epoch_consumed_g_total = float(getattr(s, "spool_epoch_consumed_g_total", 0.0) or 0.0) + float(delta_g) - state.slots[slot_id] = s - except Exception: - return - - # --- Minimal Moonraker polling (optional) --- def _http_get_json(url: str, timeout: float = 2.5) -> dict: @@ -469,6 +654,11 @@ def _spoolman_base_url() -> str: return (cfg.get("spoolman_url") or "").rstrip("/") +def _spoolman_mode() -> str: + """Return the Spoolman sync mode: 'direct' or 'moonraker'.""" + return load_config().get("spoolman_mode", "direct") + + def _spoolman_get_spools(base: str) -> list[dict]: """GET /api/v1/spool — return non-archived spools.""" url = base + "/api/v1/spool" @@ -520,588 +710,1130 @@ def _spoolman_report_measure(spool_id: int, weight_g: float) -> None: print(f"[SPOOLMAN] measure report failed for spool {spool_id}: {e}") -def _color_distance(hex1: str, hex2: str) -> float: - """Simple Euclidean RGB distance between two hex colors.""" +def _spoolman_remaining_weight(spool: dict) -> float: 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) + return max(0.0, float(spool.get("remaining_weight") or 0.0)) except Exception: - return 999.0 + 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 _moonraker_fetch_history(base: str, limit: int = 20) -> list[dict]: - """Fetch Moonraker job history list (best effort). - 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 _spoolman_id_or_none(value) -> Optional[int]: 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 - 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 - except Exception: - pass + sid = int(value) + return sid if sid > 0 else None + except Exception: + return None - 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 +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_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] - 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), - } - ) - return out + 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 "" + + +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 + + +# --- SSH serial number auto-link --- +# Uses sshpass + system ssh binary for compatibility with Creality K2 Plus firmware. +# Tries multiple passwords and multiple file paths to support stock and K2-Improvements firmwares. + +_SSH_PASSWORDS = ["creality_2023", "creality_2024", "creality"] +# Per-printer cached working password (reset never — stays valid across reconnects) +_ssh_working_password: Dict[str, Optional[str]] = {} +# Stock firmware path; K2-Improvements moves UDISK to /mnt/UDISK +_SSH_FILE_PATHS = [ + "/mnt/UDISK/creality/userdata/box/material_box_info.json", + "/usr/data/creality/userdata/box/material_box_info.json", +] -def _moonraker_build_url(base: str, objects: list[str]) -> str: - """Build Moonraker objects/query URL. - 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 +async def _fetch_printer_material_json(printer_id: str) -> Optional[dict]: + """Fetch material_box_info.json from the printer via sshpass + system ssh.""" + host = (_printer_address(printer_id) or "").strip().split(":")[0] + if not host: + return None - 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 + def _ssh_cat() -> Optional[dict]: + import subprocess + working = _ssh_working_password.get(printer_id) + candidates = ( + [working] + [p for p in _SSH_PASSWORDS if p != working] + if working else _SSH_PASSWORDS + ) + try: + for password in candidates: + # Try all known file paths with this password in one shell command + cmd = " || ".join(f"cat {p}" for p in _SSH_FILE_PATHS) + result = subprocess.run( + [ + "sshpass", "-p", password, + "ssh", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=5", + f"root@{host}", + cmd, + ], + capture_output=True, text=True, timeout=10, + ) + if result.returncode == 0 and result.stdout.strip(): + if _ssh_working_password.get(printer_id) != password: + print(f"[SSH] ({printer_id}) authenticated with password {password!r}") + _ssh_working_password[printer_id] = password + return json.loads(result.stdout) + # Exit code 5 = sshpass auth failure — try next password + if result.returncode != 5: + print(f"[SSH] fetch failed ({host}): {result.stderr.strip() or 'no output'}") + return None + print(f"[SSH] all passwords failed for {host}") + 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 _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 _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 -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) + boxes = (info.get("Material", {}).get("info") or []) + print(f"[SSH] ({printer_id}) material_box_info.json contains {len(boxes)} box(es): " + f"{[b.get('boxID') for b in boxes if isinstance(b, dict)]}") + for box in boxes: + box_id_str = box.get("boxID", "") # "T1" .. "T4" + if not box_id_str.startswith("T"): + continue + box_num = box_id_str[1:] -_SLOT_RE = __import__("re").compile(r"^[1-4][A-D]$") + 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 -def _extract_cfs_slot_data(status: dict) -> tuple[Optional[str], dict]: - """Best-effort extraction of CFS slot metadata from Moonraker status. + slot_obj = st.slots.get(slot) + if slot_obj and getattr(slot_obj, "spoolman_id", None) == spool_id: + continue # already linked - 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). - """ - 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". - 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: + 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 - 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): + except Exception as e: + print(f"[SSH] Slot {slot}: Spoolman lookup failed for spool {spool_id}: {e}") continue - # 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, - } + 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}") - # 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 + if changed: + save_state(printer_id, st) - # 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} - continue - colors = t.get("color_value") - mats = t.get("material_type") - if not (isinstance(colors, list) and isinstance(mats, list)): - 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} +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) - # 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 +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: - pass + return 999.0 - # 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 - # 2) Walk nested structures to find slot-like dicts - for p, v in _walk(status or {}): - if not isinstance(v, dict): - 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): - 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] - break - for vk in ("vendor", "manufacturer", "brand"): - if vk in raw and isinstance(raw[vk], str): - out["manufacturer"] = raw[vk] - break +_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_live_status: Dict[str, dict] = {} # printer_id → live temps/progress (in-memory only) - norm[sid] = out or {"raw": raw} +_VALID_CFS_SLOT_IDS = frozenset( + f"{b}{l}" for b in "1234" for l in "ABCD" +) + +# Log unknown WS message top-level keys once per session to aid discovery +_ws_seen_keys: Dict[str, set] = {} + +# 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 - return active, norm +# 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 _printer_ws_url(printer_id: str) -> str: + host = _printer_address(printer_id) + if not host: + return "" + return f"ws://{host.split(':')[0]}:9999" -async def moonraker_poll_loop() -> None: +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() - base = (cfg.get("moonraker_url") or "").strip() + 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 _moonraker_send_gcode(printer_id: str, script: str) -> bool: + """POST a gcode script to Moonraker. Returns True on success.""" + base = _moonraker_base_url(printer_id) + if not base: + print(f"[MOON] ({printer_id}) send_gcode skipped — no Moonraker URL configured (script: {script!r})") + return False + url = f"{base}/printer/gcode/script" + try: + body = json.dumps({"script": script}).encode() + req = UrlRequest(url, data=body, headers={"Content-Type": "application/json"}, method="POST") + with urlopen(req, timeout=5.0) as resp: + if resp.status != 200: + print(f"[MOON] ({printer_id}) send_gcode HTTP {resp.status} for {script!r}") + return False + return True + except Exception as exc: + print(f"[MOON] ({printer_id}) send_gcode exception for {script!r}: {exc}") + return False + + +def _fetch_webcam_url(printer_id: str) -> str: + """Query Moonraker's webcam list and return the first enabled stream URL. + + Relative URLs (e.g. /webcam/?action=stream) are resolved against port 4408 + which is the standard nginx proxy port on Creality K1/K1C firmware. + """ + base = _moonraker_base_url(printer_id) if not base: + return "" + try: + data = _http_get_json(f"{base}/server/webcams/list", timeout=5.0) + webcams = (data.get("result") or {}).get("webcams") or [] + for wc in webcams: + if not wc.get("enabled", True): + continue + stream_url = str(wc.get("stream_url") or "").strip() + if not stream_url: + continue + if stream_url.startswith("http"): + return stream_url + parsed = urlparse(base) + return f"{parsed.scheme}://{parsed.hostname}:4408{stream_url}" + return "" + except Exception as e: + print(f"[MOON] ({printer_id}) webcam URL fetch failed: {e}") + return "" + + +def _moonraker_set_active_spool(printer_id: str, spool_id: Optional[int]) -> None: + """Call SET_ACTIVE_SPOOL or CLEAR_ACTIVE_SPOOL on the printer via Moonraker. + + Runs the blocking HTTP call in a background thread so neither the event + loop nor a sync worker thread is blocked (the gcode POST can take up to + 5 s to time out). Using a plain thread avoids asyncio.ensure_future + failing when called from a sync FastAPI endpoint running in an AnyIO + worker thread that has no current event loop. + """ + cmd = f"SET_ACTIVE_SPOOL ID={spool_id}" if spool_id else "CLEAR_ACTIVE_SPOOL" + + def _run() -> None: + ok = _moonraker_send_gcode(printer_id, cmd) + print(f"[MOON] ({printer_id}) {cmd} — {'OK' if ok else 'FAILED'}") + + threading.Thread(target=_run, daemon=True).start() + + +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[CfsEnvSample] = [] + if isinstance(raw_hist, list): + for item in raw_hist: + coerced = _coerce_cfs_env_sample(item) + if coerced: + hist.append(_model_validate(CfsEnvSample, coerced)) + + should_append = True + if hist: + last = hist[-1] + last_ts = _as_finite_float_or_none(last.ts) or 0.0 + dt = ts - last_ts + prev_t = _as_finite_float_or_none(last.temperature_c) + prev_h = _as_finite_float_or_none(last.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 + + hist.append(CfsEnvSample( + ts=float(ts), + temperature_c=round(temp, 2) if temp is not None else None, + humidity_pct=round(hum, 2) if hum is not None else None, + )) + if len(hist) > _CFS_ENV_MAX_POINTS: + hist = hist[-_CFS_ENV_MAX_POINTS:] + st.cfs_env_history[box_key] = hist + + +def _parse_ws_printer_info(payload: dict, printer_id: str) -> None: + """Extract printer name / firmware from any WS status message and persist to state. + + Also logs any previously-unseen top-level keys once per session so we can + discover the exact field names the printer uses. + """ + 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 - interval = float(cfg.get("poll_interval_sec", 5) or 5) - if interval < 1: - interval = 1 + 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) + - # Always query job usage - base_objects = ["print_stats", "virtual_sdcard"] +# Per-printer in-process active slot tracker (for Moonraker mode notification) +_ws_active_slot: Dict[str, Any] = {} +_WS_ACTIVE_SLOT_SENTINEL = object() - # Best-effort: discover CFS-related objects once, then include them in polling. - cfs_objects: list[str] = [] + +def _parse_ws_cfs_data(payload: dict, printer_id: str) -> None: + """Parse a boxsInfo WS payload and update local state + Spoolman.""" 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] + boxes = (payload.get("boxsInfo") or {}).get("materialBoxs") or [] except Exception: - cfs_objects = [] + return - poll_objects = base_objects + cfs_objects - url = _moonraker_build_url(base, poll_objects) + 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): + if _spoolman_mode() == "moonraker" and ( + active_slot == slot + or (slot == PRINTER_SPOOL_SLOT and not active_slot) + ): + _moonraker_set_active_spool(printer_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) + manual_pct.pop(slot, None) + _spoolman_pct_refresh_at.setdefault(printer_id, {}).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)) + + # Track RFID changes to detect implicit spool swaps (unlink on RFID change) + 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 and getattr(slot_obj2, "spoolman_id", None): + # RFID changed on a linked slot — implicit spool swap, unlink + slot_obj2.spoolman_id = None + st.slots[slot] = slot_obj2 + st.ws_slot_length_m.pop(slot, None) # reset baseline + + # 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 - # Optional: if enabled, we import material/color/name from CFS objects into our local slots. - cfs_autosync = bool(cfg.get("cfs_autosync", False)) + 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, + ) - # Pull Moonraker's global history occasionally (read-only). - last_hist_fetch = 0.0 - hist_every_sec = 60.0 + for mat in (box.get("materials") or []): + if not isinstance(mat, dict): + continue + mat_id = mat.get("id") + if not isinstance(mat_id, int) or mat_id < 0 or mat_id > 3: + continue - while 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 {} + 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 - ps_state = str(ps.get("state") or "").lower() + 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": "", + } - 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 + # 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 + + # Moonraker mode: notify printer when the active CFS slot changes. + # Only fire when the firmware explicitly selects a CFS slot (active_slot is non-None). + # SP activation is handled at job-start time in moonraker_job_poll_loop to avoid + # spurious SET_ACTIVE_SPOOL calls during multi-color slot transitions (firmware + # briefly sends selected=0 for all CFS slots while switching). + prev_active = _ws_active_slot.get(printer_id, _WS_ACTIVE_SLOT_SENTINEL) + if _spoolman_mode() == "moonraker" and active_slot and active_slot != prev_active: + new_spool_id = (st.slots[active_slot].spoolman_id + if active_slot in st.slots else None) + _moonraker_set_active_spool(printer_id, new_spool_id) + _ws_active_slot[printer_id] = active_slot + + # 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 + 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 + 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: - used_mm = int(float(used)) + 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 + - used_g = 0.0 +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: - 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)) - 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 + 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 - # --- 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. + # 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: - 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, - }, - ) - # Sync consumption to Spoolman if linked - try: - slot_obj = st.slots.get(sid) - if slot_obj and getattr(slot_obj, "spoolman_id", None) and g > 0: - _spoolman_report_usage(slot_obj.spoolman_id, g) - except Exception: - pass - except Exception: - continue - - # 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 - except Exception: - pass + reply = await asyncio.wait_for(ws.recv(), timeout=2.0) + if str(reply).strip() == "ok": + break + except asyncio.TimeoutError: + break - # --- 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)) + 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 + + # Plain "ok" is the printer acking our heartbeat — nothing to do + if isinstance(msg, str) and msg.strip() == "ok": + 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 + 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 - save_state(st) + # 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 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 + + print(f"[WS] ({printer_id}) Starting WebSocket loop for {ws_url}") + backoff = 2.0 + + 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: - st = load_state() + last_err = str(e) + print(f"[WS] ({printer_id}) Connection lost: {e}") + + try: + st = load_state(printer_id) st.printer_connected = False - st.printer_last_error = str(e) - st.updated_at = time.time() - save_state(st) + st.cfs_connected = False + st.printer_last_error = last_err + save_state(printer_id, st) + except Exception: + pass + + print(f"[WS] ({printer_id}) Reconnecting in {backoff:.0f}s…") + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 60.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: + if _spoolman_mode() == "direct": + _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 (moonraker mode, plugin tracks usage)") + 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 + history = st.job_history if isinstance(st.job_history, list) else [] + history.append({ + "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), + }) + st.job_history = history[-50:] + + # Invalidate Spoolman percent cache so next WS parse picks up updated remaining_weight + for slot in job_g: + _spoolman_pct_refresh_at.setdefault(printer_id, {}).pop(slot, None) + + if any(g > 0 for g in job_g.values()) or bool(st.job_history): + 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 + + +def _resolve_tracking_slot(st: AppState) -> Optional[str]: + """Three-tier priority: CFS active slot → SP slot (when no CFS slot active) → legacy active_slot.""" + # 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 + + # No active CFS slot — use direct spool input if it is present. + # This also covers the case where CFS is connected but the printer is + # currently feeding from the external spool holder (selected=0 on all CFS slots). + 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 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 + + +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"} + + # Fetch and persist webcam URL once on startup + webcam_url = _fetch_webcam_url(printer_id) + if webcam_url: + _st0 = load_state(printer_id) + if _st0.moon_webcam_url != webcam_url: + _st0.moon_webcam_url = webcam_url + save_state(printer_id, _st0) + print(f"[MOON] ({printer_id}) Webcam stream: {webcam_url}") + + while True: + await asyncio.sleep(5.0) + try: + url = f"{base}/printer/objects/query?print_stats&extruder&heater_bed&display_status" + data = _http_get_json(url, timeout=5.0) + status = (data.get("result") or {}).get("status", {}) + ps = status.get("print_stats") or {} + ext = status.get("extruder") or {} + bed = status.get("heater_bed") or {} + disp = status.get("display_status") or {} + + new_state = str(ps.get("state") or "").lower() + filament_used_mm = float(ps.get("filament_used") or 0) + + # Update live status (temps + progress) — kept in memory only, not persisted + _moon_live_status[printer_id] = { + "moon_nozzle_temp": round(float(ext.get("temperature") or 0), 1), + "moon_nozzle_target": round(float(ext.get("target") or 0), 1), + "moon_bed_temp": round(float(bed.get("temperature") or 0), 1), + "moon_bed_target": round(float(bed.get("target") or 0), 1), + "moon_progress": round(float(disp.get("progress") or 0), 4), + "moon_print_filename": str(ps.get("filename") or "").strip(), + "moon_print_duration_s": int(float(ps.get("print_duration") or 0)), + # Exposed so the frontend can distinguish "homing/meshing" from "extruding" + "moon_filament_used_mm": round(filament_used_mm, 1), + } + 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 != prev: + _st = load_state(printer_id) + _st.moon_print_state = new_state + save_state(printer_id, _st) + + 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") + # Moonraker mode: if printing from SP (no CFS slot active), activate SP's spool now. + # CFS slot activations are handled in _parse_boxs_info via the WS stream. + if _spoolman_mode() == "moonraker": + _st_job = load_state(printer_id) + if _resolve_tracking_slot(_st_job) == PRINTER_SPOOL_SLOT: + sp_spool_id = _st_job.slots[PRINTER_SPOOL_SLOT].spoolman_id if PRINTER_SPOOL_SLOT in _st_job.slots else None + _moonraker_set_active_spool(printer_id, sp_spool_id) + + 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) - await asyncio.sleep(interval) + 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 Printer 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") @@ -1127,9 +1859,12 @@ 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()) + 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("/") @@ -1139,91 +1874,9 @@ def index(): # --- 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) - - # 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)) - # Sync consumption to Spoolman if linked - try: - slot_obj = st.slots.get(sid) - if slot_obj and getattr(slot_obj, "spoolman_id", None) and float(g) > 0: - _spoolman_report_usage(slot_obj.spoolman_id, float(g)) - except Exception: - pass - - save_state(st) - return st +def api_state(printer_id: Optional[str] = None): + pid = _resolve_printer_id(printer_id, allow_unknown=printer_id is None) + return load_state(pid) def _ui_state_dict(state: AppState) -> dict: @@ -1239,76 +1892,68 @@ 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", {}) + 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() + d["spoolman_mode"] = _spoolman_mode() 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) + d["printer_id"] = pid + d.update(_moon_live_status.get(pid, {})) # inject live temps/progress + 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/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 @@ -1320,9 +1965,10 @@ def api_ui_select_slot(req: SelectSlotRequest) -> ApiResponse: @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 @@ -1334,23 +1980,26 @@ def api_ui_set_auto(req: SetAutoRequest) -> ApiResponse: @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") @@ -1375,102 +2024,59 @@ 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)) - - -@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) + save_state(pid, 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() # Roll change auto-unlinks Spoolman spool s.spoolman_id = None - # keep legacy fields for debugging only - s.spool_start_g = start_g - s.remaining_g = start_g state.slots[slot] = s - save_state(state) - return ApiResponse(result=_ui_state_dict(state)) - - -@app.post("/api/ui/spool/set_remaining", response_model=ApiResponse) -def api_ui_spool_set_remaining(req: UiSpoolSetRemainingRequest) -> ApiResponse: - """Übernehmen: set measured remaining weight as new reference (local only). - - Does NOT reset epoch and does not delete history. Remaining is computed as: - remaining = ref_remaining - (consumed_epoch - ref_consumed) - """ - state = load_state() - slot = req.slot - if slot not in state.slots: - raise HTTPException(status_code=404, detail="Unknown slot") - - rem_g = float(req.remaining_g) - s = state.slots[slot] - consumed_now = _slot_consumed_g_epoch(state, slot) - s.spool_ref_remaining_g = rem_g - s.spool_ref_consumed_g = float(round(consumed_now, 4)) - s.spool_ref_set_at = time.time() - # legacy - s.remaining_g = rem_g - state.slots[slot] = s - save_state(state) - - # Sync measured weight to Spoolman if linked - if getattr(s, "spoolman_id", None): - try: - _spoolman_report_measure(s.spoolman_id, rem_g) - except Exception: - pass - + # 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)) # --- Spoolman integration endpoints --- @app.get("/api/ui/spoolman/spools") -def api_ui_spoolman_spools(slot: str = "1A"): +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") - state = load_state() + pid = _resolve_printer_id(printer_id, allow_unknown=printer_id is None) + state = load_state(pid) s = state.slots.get(slot) - slot_material = (getattr(s, "material", "PLA") or "PLA").upper() if s else "PLA" - slot_color = (getattr(s, "color_hex", "") or "").lower() if s else "" + 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_color = (getattr(s, "color_hex", "") or "").lower() if (s and slot_present) else "" try: raw = _spoolman_get_spools(base) @@ -1507,7 +2113,7 @@ def api_ui_spoolman_spools(slot: str = "1A"): for sp in spools: del sp["_score"] - return {"spools": spools, "slot": slot} + return {"spools": spools, "slot": slot, "printer_id": pid} @app.post("/api/ui/spoolman/link", response_model=ApiResponse) @@ -1517,7 +2123,8 @@ def api_ui_spoolman_link(req: SpoolmanLinkRequest) -> ApiResponse: if not base: raise HTTPException(status_code=400, detail="Spoolman URL not configured") - 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") @@ -1527,8 +2134,6 @@ def api_ui_spoolman_link(req: SpoolmanLinkRequest) -> ApiResponse: except Exception as e: raise HTTPException(status_code=502, detail=f"Spoolman unreachable: {e}") - # Import remaining_weight from Spoolman - rem_g = float(sp.get("remaining_weight") or 0.0) filament = sp.get("filament") or {} s = state.slots[slot] @@ -1548,96 +2153,192 @@ def api_ui_spoolman_link(req: SpoolmanLinkRequest) -> ApiResponse: if vendor_name: s.manufacturer = vendor_name - # Set remaining as reference (same logic as set_remaining) - consumed_now = _slot_consumed_g_epoch(state, slot) - s.spool_ref_remaining_g = rem_g - s.spool_ref_consumed_g = float(round(consumed_now, 4)) - s.spool_ref_set_at = time.time() - s.remaining_g = rem_g - state.slots[slot] = s - save_state(state) + save_state(pid, state) + + # Moonraker mode: notify printer when a spool is linked on the currently active slot. + # SP is never set as cfs_active_slot by firmware, so check it separately. + if _spoolman_mode() == "moonraker": + sp_meta_lnk = state.cfs_slots.get(PRINTER_SPOOL_SLOT) if isinstance(state.cfs_slots, dict) else None + sp_present_lnk = isinstance(sp_meta_lnk, dict) and bool(sp_meta_lnk.get("present")) + if state.cfs_active_slot == slot or (slot == PRINTER_SPOOL_SLOT and sp_present_lnk and not state.cfs_active_slot): + _moonraker_set_active_spool(pid, req.spoolman_id) + return ApiResponse(result=_ui_state_dict(state)) @app.post("/api/ui/spoolman/unlink", response_model=ApiResponse) def api_ui_spoolman_unlink(req: SpoolmanUnlinkRequest) -> ApiResponse: """Clear Spoolman link on a slot. Local tracking is unaffected.""" - state = load_state() + 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") + if _spoolman_mode() == "moonraker": + sp_meta_ulnk = state.cfs_slots.get(PRINTER_SPOOL_SLOT) if isinstance(state.cfs_slots, dict) else None + sp_present_ulnk = isinstance(sp_meta_ulnk, dict) and bool(sp_meta_ulnk.get("present")) + if state.cfs_active_slot == slot or (slot == PRINTER_SPOOL_SLOT and sp_present_ulnk and not state.cfs_active_slot): + _moonraker_set_active_spool(pid, None) state.slots[slot].spoolman_id = None - save_state(state) + save_state(pid, state) return ApiResponse(result=_ui_state_dict(state)) -@app.post("/api/ui/set_color", response_model=ApiResponse) -def api_ui_set_color(req: UiSetColorRequest) -> ApiResponse: - state = load_state() - if req.slot not in state.slots: - raise HTTPException(status_code=404, detail="Unknown slot") - state.slots[req.slot].color_hex = req.color - save_state(state) - return ApiResponse(result=_ui_state_dict(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") -@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 + pid = _resolve_printer_id(req.printer_id, allow_unknown=req.printer_id is None) + state = load_state(pid) + history = state.job_history if isinstance(state.job_history, list) else [] + target_spool = None + req_slot = str(req.slot) + req_ended_at = float(req.ended_at) + for job in reversed(history): + if not isinstance(job, dict): + continue + try: + ended_at = float(job.get("ended_at") or 0.0) + except Exception: + ended_at = 0.0 + if abs(ended_at - req_ended_at) > 1.0: + continue + spools = job.get("spools") or [] + if not isinstance(spools, list): + continue + for sp in spools: + if not isinstance(sp, dict): + continue + if str(sp.get("slot") or "") == req_slot: + target_spool = sp + break + if target_spool: + break -@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") + if target_spool is None: + raise HTTPException(status_code=404, detail="Job spool entry not found") - 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") + try: + new_spool = _spoolman_get_spool(base, req.spoolman_id) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Spoolman unreachable: {e}") - new_val = max(0.0, float(current) - float(req.used_g)) - state.slots[req.slot].remaining_g = new_val - save_state(state) - return state + grams = max(0.0, float(target_spool.get("grams") or 0.0)) + old_spool_id = _spoolman_id_or_none(target_spool.get("spoolman_id")) + new_spool_id = int(req.spoolman_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 {} + target_spool["spoolman_id"] = new_spool_id + if filament.get("material") is not None: + target_spool["material"] = str(filament.get("material") or "").upper() + if filament.get("name") is not None: + target_spool["name"] = str(filament.get("name") or "") + if filament.get("vendor") is not None: + target_spool["manufacturer"] = str((filament.get("vendor") or {}).get("name") or "") + target_spool["color_hex"] = _normalize_color_hex(str(filament.get("color_hex") or "")) + + state.job_history = history[-50:] + save_state(pid, state) + return ApiResponse(result=_ui_state_dict(state)) -@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 +@app.post("/api/ui/set_spoolman_mode", response_model=ApiResponse) +def api_ui_set_spoolman_mode(req: SetSpoolmanModeRequest) -> ApiResponse: + """Switch Spoolman sync mode between 'direct' and 'moonraker'.""" + if req.mode not in ("direct", "moonraker"): + raise HTTPException(status_code=400, detail="mode must be 'direct' or 'moonraker'") + cfg = load_config() + cfg["spoolman_mode"] = req.mode + CONFIG_PATH.write_text(json.dumps(cfg, indent=2, ensure_ascii=False)) + print(f"[CONFIG] spoolman_mode set to {req.mode!r}") + pid = _default_printer_id() + state = load_state(pid) + return ApiResponse(result=_ui_state_dict(state)) -@app.post("/api/ui/job/set", response_model=ApiResponse) -def api_ui_job_set(req: JobSetRequest) -> ApiResponse: - state = api_job_set(req) +@app.post("/api/ui/set_spoolman_url", response_model=ApiResponse) +def api_ui_set_spoolman_url(req: SetSpoolmanUrlRequest) -> ApiResponse: + """Set (or clear) the Spoolman server URL.""" + url = req.url.strip().rstrip("/") + cfg = load_config() + cfg["spoolman_url"] = url + CONFIG_PATH.write_text(json.dumps(cfg, indent=2, ensure_ascii=False)) + print(f"[CONFIG] spoolman_url set to {url!r}") + pid = _default_printer_id() + state = load_state(pid) return ApiResponse(result=_ui_state_dict(state)) -@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.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. + 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} + + base = _spoolman_base_url() + if not base: + return {"linked": True, "slot": slot, "spool": None, "error": "not_configured", "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) + 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/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)) @@ -1670,18 +2371,14 @@ def api_ui_help(lang: str = "de") -> ApiResponse: if lang == "en": text = ( "Click a slot to set it as active.\n" - "Use the color presets to set the color on the active slot.\n" - "Feed/Retract are currently adapter hooks (dummy) until real hardware is connected.\n" - "Job consumption: If you use Moonraker, set moonraker_url in data/config.json — job + filament_used will be picked up automatically.\n" - "Alternatively you can use /api/ui/job/update manually." + "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" - "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." + "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}) @@ -1692,35 +2389,40 @@ def api_health(): return {"ok": True, "ts": _now()} - 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/mock_printer.py b/mock_printer.py new file mode 100644 index 0000000..f8a21c7 --- /dev/null +++ b/mock_printer.py @@ -0,0 +1,509 @@ +#!/usr/bin/env python3 +""" +mock_printer.py — Mock Creality K2 Plus / Moonraker server for testing CFSync. + +Simulates: + - WebSocket CFS server on port 9999 (boxsInfo, heartbeat protocol) + - Moonraker HTTP API on port 7125 (print_stats, gcode/script) + - Control API at /mock/* (change state without a real printer) + +Usage (run each in a separate terminal): + python3 mock_printer.py --host 127.0.0.2 --name "Printer Alpha" + python3 mock_printer.py --host 127.0.0.3 --name "Printer Beta" + +Then in CFSync data/config.json: + {"printer_urls": ["127.0.0.2", "127.0.0.3"]} + +On Linux, all 127.x.x.x addresses work as loopback aliases without extra config. + +Control API examples: + curl http://127.0.0.2:7125/mock/state + curl -X POST http://127.0.0.2:7125/mock/print/start + curl -X POST http://127.0.0.2:7125/mock/print/complete + curl -X POST http://127.0.0.2:7125/mock/print/cancel + curl -X POST http://127.0.0.2:7125/mock/slot/1A \\ + -H 'Content-Type: application/json' \\ + -d '{"state": 2, "type": "PETG", "color": "#00FF00", "name": "Green PETG", "vendor": "Bambu"}' + curl -X POST http://127.0.0.2:7125/mock/slot/1A/empty + +SSH auto-linking (mocked without a real SSH server): + # Run CFSync with mock_sshpass.sh on PATH so it intercepts SSH calls: + PATH="$PWD:$PATH" uvicorn main:app --reload --host 0.0.0.0 --port 8000 + + # Set slot RFID to a plain Spoolman spool ID integer to trigger auto-link: + curl -X POST http://127.0.0.2:7125/mock/slot/1A \\ + -H 'Content-Type: application/json' \\ + -d '{"state": 2, "rfid": "42"}' # links to Spoolman spool ID 42 +""" + +import argparse +import asyncio +import json +import time +from typing import Optional + +import uvicorn +import websockets +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +# --------------------------------------------------------------------------- +# Default CFS slot configuration +# --------------------------------------------------------------------------- + +def _default_slots() -> list[dict]: + """Return a realistic 4-box CFS state with mixed slot occupancy.""" + materials = [ + # Box 1 — fully loaded with RFID spools + {"box": 1, "id": 0, "state": 2, "type": "PLA", "color": "#FF5733", "name": "Orange PLA", "vendor": "Bambu", "rfid": "AABBCC001", "selected": 1, "usedMaterialLength": 1250.5}, + {"box": 1, "id": 1, "state": 2, "type": "PLA", "color": "#3375FF", "name": "Blue PLA", "vendor": "Bambu", "rfid": "AABBCC002", "selected": 0, "usedMaterialLength": 340.0}, + {"box": 1, "id": 2, "state": 2, "type": "PETG", "color": "#33FF57", "name": "Green PETG", "vendor": "eSUN", "rfid": "AABBCC003", "selected": 0, "usedMaterialLength": 820.0}, + {"box": 1, "id": 3, "state": 2, "type": "PETG", "color": "#FF33F5", "name": "Pink PETG", "vendor": "eSUN", "rfid": "AABBCC004", "selected": 0, "usedMaterialLength": 100.0}, + # Box 2 — 2 RFID, 2 empty + {"box": 2, "id": 0, "state": 2, "type": "ABS", "color": "#FFFFFF", "name": "White ABS", "vendor": "Polymaker", "rfid": "AABBCC005", "selected": 0, "usedMaterialLength": 500.0}, + {"box": 2, "id": 1, "state": 1, "type": "TPU", "color": "#000000", "name": "Black TPU", "vendor": "NinjaFlex", "rfid": "", "selected": 0, "usedMaterialLength": 60.0}, + {"box": 2, "id": 2, "state": 0, "type": "", "color": "", "name": "", "vendor": "", "rfid": "", "selected": 0, "usedMaterialLength": 0.0}, + {"box": 2, "id": 3, "state": 0, "type": "", "color": "", "name": "", "vendor": "", "rfid": "", "selected": 0, "usedMaterialLength": 0.0}, + # Box 3 — mostly empty + {"box": 3, "id": 0, "state": 2, "type": "PLA", "color": "#FFD700", "name": "Gold PLA", "vendor": "Hatchbox", "rfid": "AABBCC006", "selected": 0, "usedMaterialLength": 2000.0}, + {"box": 3, "id": 1, "state": 0, "type": "", "color": "", "name": "", "vendor": "", "rfid": "", "selected": 0, "usedMaterialLength": 0.0}, + {"box": 3, "id": 2, "state": 0, "type": "", "color": "", "name": "", "vendor": "", "rfid": "", "selected": 0, "usedMaterialLength": 0.0}, + {"box": 3, "id": 3, "state": 0, "type": "", "color": "", "name": "", "vendor": "", "rfid": "", "selected": 0, "usedMaterialLength": 0.0}, + # Box 4 — all empty + {"box": 4, "id": 0, "state": 0, "type": "", "color": "", "name": "", "vendor": "", "rfid": "", "selected": 0, "usedMaterialLength": 0.0}, + {"box": 4, "id": 1, "state": 0, "type": "", "color": "", "name": "", "vendor": "", "rfid": "", "selected": 0, "usedMaterialLength": 0.0}, + {"box": 4, "id": 2, "state": 0, "type": "", "color": "", "name": "", "vendor": "", "rfid": "", "selected": 0, "usedMaterialLength": 0.0}, + {"box": 4, "id": 3, "state": 0, "type": "", "color": "", "name": "", "vendor": "", "rfid": "", "selected": 0, "usedMaterialLength": 0.0}, + ] + return materials + + +# --------------------------------------------------------------------------- +# Shared mock state (mutated by control API and simulated print loop) +# --------------------------------------------------------------------------- + +class MockState: + def __init__(self, name: str, firmware: str): + self.name = name + self.firmware = firmware + + # Flat list of material dicts: {"box": 1..4, "id": 0..3, "state", ...} + self.materials: list[dict] = _default_slots() + + # Print job state ("standby" | "printing" | "paused" | "complete" | "error" | "cancelled") + self.print_state: str = "standby" + self.print_filename: str = "" + self.filament_used_mm: float = 0.0 + self._print_started_at: Optional[float] = None + + # Connected WebSocket clients for pushing updates + self._ws_clients: set = set() + self._lock = asyncio.Lock() + + def _slot_key(self, box: int, mat_id: int) -> int: + return (box - 1) * 4 + mat_id + + def get_material(self, box: int, mat_id: int) -> Optional[dict]: + for m in self.materials: + if m["box"] == box and m["id"] == mat_id: + return m + return None + + def set_material(self, slot_str: str, data: dict) -> bool: + """Update a slot by slot string like '1A', '2C', 'SP'.""" + if slot_str == "SP": + return False # SP not supported in mock CFS + if len(slot_str) != 2 or slot_str[0] not in "1234" or slot_str[1] not in "ABCD": + return False + box = int(slot_str[0]) + mat_id = "ABCD".index(slot_str[1]) + for m in self.materials: + if m["box"] == box and m["id"] == mat_id: + m.update(data) + return True + return False + + def build_boxes_info(self) -> dict: + """Build the boxsInfo WS payload from current state.""" + # Group by box + boxes_by_id: dict[int, list] = {1: [], 2: [], 3: [], 4: []} + for m in self.materials: + boxes_by_id[m["box"]].append({ + "id": m["id"], + "state": m["state"], + "type": m["type"], + "color": m["color"], + "name": m["name"], + "vendor": m["vendor"], + "rfid": m["rfid"], + "selected": m["selected"], + "usedMaterialLength": m["usedMaterialLength"], + }) + + material_boxs = [] + for box_id in [1, 2, 3, 4]: + material_boxs.append({ + "type": 0, + "id": box_id, + "temp": 23.5 + box_id * 0.3, + "humidity": 42.0 - box_id * 1.5, + "materials": sorted(boxes_by_id[box_id], key=lambda x: x["id"]), + }) + + # Also add the direct spool holder (type=1) — always empty in mock + material_boxs.append({ + "type": 1, + "materials": [{"id": 0, "state": 0, "type": "", "color": "", "name": "", + "vendor": "", "rfid": "", "selected": 0, "usedMaterialLength": 0.0}], + }) + + return { + "hostname": self.name, + "softVersion": self.firmware, + "boxsInfo": { + "materialBoxs": material_boxs, + }, + } + + def build_material_box_info(self) -> dict: + """Build the SSH material_box_info.json format from current slot state. + + Used by the /mock/material_box_info endpoint and the fake sshpass script. + serialNum is set to a fake integer for slots that have RFID (state=2). + Override via POST /mock/slot/{id} with a real Spoolman spool ID to test auto-linking. + """ + boxes: dict[str, list] = {} + for m in self.materials: + box_key = f"T{m['box']}" + if box_key not in boxes: + boxes[box_key] = [] + serial = "" + # Only include serialNum for RFID slots (state=2); use RFID string hash as fake ID + if m["state"] == 2 and m.get("rfid"): + # If rfid looks like a pure integer, use it directly as the Spoolman spool ID + rfid = m["rfid"].strip() + serial = rfid if rfid.isdigit() else "" + boxes[box_key].append({ + "materialId": "ABCD"[m["id"]], + "serialNum": serial, + }) + + return { + "Material": { + "info": [ + {"boxID": box_key, "list": items} + for box_key, items in sorted(boxes.items()) + ] + } + } + + def build_print_stats(self) -> dict: + return { + "result": { + "status": { + "print_stats": { + "state": self.print_state, + "filament_used": self.filament_used_mm, + "filename": self.print_filename, + "job_name": self.print_filename, + } + } + } + } + + async def start_print(self, filename: str = "mock_print.gcode") -> None: + self.print_state = "printing" + self.print_filename = filename + self.filament_used_mm = 0.0 + self._print_started_at = time.time() + print(f"[MOCK] ({self.name}) Print started: {filename}") + + async def complete_print(self) -> None: + self.print_state = "complete" + self._print_started_at = None + print(f"[MOCK] ({self.name}) Print completed, filament_used={self.filament_used_mm:.1f}mm") + + async def cancel_print(self) -> None: + self.print_state = "cancelled" + self._print_started_at = None + print(f"[MOCK] ({self.name}) Print cancelled") + + async def push_cfs_update(self) -> None: + """Broadcast current boxsInfo to all connected WS clients.""" + if not self._ws_clients: + return + payload = json.dumps(self.build_boxes_info()) + dead = set() + for ws in list(self._ws_clients): + try: + await ws.send(payload) + except Exception: + dead.add(ws) + self._ws_clients -= dead + + +# --------------------------------------------------------------------------- +# Background task: simulate filament consumption during print +# --------------------------------------------------------------------------- + +async def _simulate_print_loop(state: MockState) -> None: + """Increment filament_used during an active print (simulates ~10mm/s usage).""" + while True: + await asyncio.sleep(2) + if state.print_state == "printing": + state.filament_used_mm += 20.0 # ~10mm/s × 2s tick + # Also bump the active slot's usedMaterialLength + for m in state.materials: + if m["selected"] == 1 and m["state"] > 0: + m["usedMaterialLength"] += 0.02 # metres + + +# --------------------------------------------------------------------------- +# WebSocket server (port 9999) — mimics Creality K2 Plus CFS hub +# --------------------------------------------------------------------------- + +async def _ws_handler(websocket, state: MockState) -> None: + """Handle one WebSocket connection from CFSync.""" + print(f"[WS] ({state.name}) Client connected: {websocket.remote_address}") + state._ws_clients.add(websocket) + + # Send initial printer identity message immediately on connect + await websocket.send(json.dumps({ + "hostname": state.name, + "softVersion": state.firmware, + })) + + try: + async for raw in websocket: + try: + msg = json.loads(raw) if isinstance(raw, str) else {} + except Exception: + # Plain string messages like "ok" + continue + + # Heartbeat handshake: {"ModeCode": "heart_beat"} → reply "ok" + if msg.get("ModeCode") == "heart_beat": + await websocket.send("ok") + continue + + # boxsInfo request: {"method": "get", "params": {"boxsInfo": 1}} + if msg.get("method") == "get" and (msg.get("params") or {}).get("boxsInfo"): + await websocket.send(json.dumps(state.build_boxes_info())) + continue + + except websockets.exceptions.ConnectionClosed: + pass + finally: + state._ws_clients.discard(websocket) + print(f"[WS] ({state.name}) Client disconnected") + + +async def _ws_heartbeat_loop(state: MockState) -> None: + """Periodically send a heartbeat ping and boxsInfo push to all WS clients.""" + while True: + await asyncio.sleep(5) + if state._ws_clients: + heartbeat = json.dumps({"heart_beat": int(time.time())}) + dead = set() + for ws in list(state._ws_clients): + try: + await ws.send(heartbeat) + # Also push fresh CFS data right after heartbeat + await ws.send(json.dumps(state.build_boxes_info())) + except Exception: + dead.add(ws) + state._ws_clients -= dead + + +# --------------------------------------------------------------------------- +# FastAPI app: Moonraker mock + control API +# --------------------------------------------------------------------------- + +def build_app(state: MockState) -> FastAPI: + app = FastAPI(title=f"Mock Printer: {state.name}") + + # ---- Moonraker endpoints ----------------------------------------------- + + @app.get("/printer/objects/query") + async def query_printer_objects(request: Request): + """Moonraker print_stats query — polled every 5s by CFSync.""" + return state.build_print_stats() + + @app.post("/printer/gcode/script") + async def gcode_script(request: Request): + """Accept gcode macros like SET_ACTIVE_SPOOL / CLEAR_ACTIVE_SPOOL.""" + try: + body = await request.json() + script = body.get("script", "") + except Exception: + script = "" + print(f"[HTTP] ({state.name}) GCode script received: {script!r}") + return {"result": "ok"} + + # ---- Control API ------------------------------------------------------- + + @app.get("/mock/state") + async def mock_get_state(): + """Return full mock state for inspection.""" + slots = {} + for m in state.materials: + slot_str = f"{m['box']}{'ABCD'[m['id']]}" + slots[slot_str] = {k: v for k, v in m.items() if k not in ("box", "id")} + return { + "name": state.name, + "firmware": state.firmware, + "print_state": state.print_state, + "print_filename": state.print_filename, + "filament_used_mm": state.filament_used_mm, + "ws_clients": len(state._ws_clients), + "slots": slots, + } + + @app.post("/mock/print/start") + async def mock_start_print(request: Request): + body = {} + try: + body = await request.json() + except Exception: + pass + filename = body.get("filename", "mock_print.gcode") + await state.start_print(filename) + await state.push_cfs_update() + return {"ok": True, "print_state": state.print_state} + + @app.post("/mock/print/complete") + async def mock_complete_print(): + await state.complete_print() + return {"ok": True, "print_state": state.print_state} + + @app.post("/mock/print/cancel") + async def mock_cancel_print(): + await state.cancel_print() + return {"ok": True, "print_state": state.print_state} + + @app.post("/mock/print/pause") + async def mock_pause_print(): + if state.print_state == "printing": + state.print_state = "paused" + print(f"[MOCK] ({state.name}) Print paused") + return {"ok": True, "print_state": state.print_state} + + @app.post("/mock/print/resume") + async def mock_resume_print(): + if state.print_state == "paused": + state.print_state = "printing" + print(f"[MOCK] ({state.name}) Print resumed") + return {"ok": True, "print_state": state.print_state} + + @app.post("/mock/slot/{slot_id}") + async def mock_set_slot(slot_id: str, request: Request): + """ + Update a CFS slot. Body fields (all optional): + state : 0=empty, 1=manual, 2=RFID + type : "PLA" / "PETG" / "ABS" / ... + color : "#rrggbb" + name : spool name + vendor : manufacturer + rfid : RFID tag string (required for state=2) + selected: 0 or 1 + """ + try: + data = await request.json() + except Exception: + return JSONResponse({"error": "invalid JSON"}, status_code=400) + if not state.set_material(slot_id.upper(), data): + return JSONResponse({"error": f"unknown slot {slot_id!r}"}, status_code=404) + await state.push_cfs_update() + return {"ok": True, "slot": slot_id.upper()} + + @app.post("/mock/slot/{slot_id}/empty") + async def mock_empty_slot(slot_id: str): + """Convenience: mark a slot as empty.""" + if not state.set_material(slot_id.upper(), { + "state": 0, "type": "", "color": "", "name": "", + "vendor": "", "rfid": "", "selected": 0, "usedMaterialLength": 0.0, + }): + return JSONResponse({"error": f"unknown slot {slot_id!r}"}, status_code=404) + await state.push_cfs_update() + return {"ok": True, "slot": slot_id.upper()} + + @app.get("/mock/material_box_info") + async def mock_material_box_info(): + """ + Returns the material_box_info.json structure that CFSync normally reads via SSH. + + Used by mock_sshpass.sh to intercept SSH calls without a real SSH server. + For SSH auto-linking to work, set the rfid field to a plain integer matching + the Spoolman spool ID: + curl -X POST http://127.0.0.2:7125/mock/slot/1A \\ + -H 'Content-Type: application/json' \\ + -d '{"state": 2, "rfid": "42"}' + """ + return state.build_material_box_info() + + @app.post("/mock/slot/{slot_id}/select") + async def mock_select_slot(slot_id: str): + """Set the active/selected slot (simulates CFS switching).""" + # Deselect all first + for m in state.materials: + m["selected"] = 0 + if not state.set_material(slot_id.upper(), {"selected": 1}): + return JSONResponse({"error": f"unknown slot {slot_id!r}"}, status_code=404) + await state.push_cfs_update() + return {"ok": True, "active_slot": slot_id.upper()} + + return app + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +async def main(host: str, name: str, firmware: str) -> None: + state = MockState(name=name, firmware=firmware) + + # Start background tasks + asyncio.create_task(_simulate_print_loop(state)) + asyncio.create_task(_ws_heartbeat_loop(state)) + + # Start WebSocket server on port 9999 + ws_server = await websockets.serve( + lambda ws: _ws_handler(ws, state), + host=host, + port=9999, + ) + print(f"[MOCK] WebSocket server listening on ws://{host}:9999") + + # Start FastAPI / Moonraker HTTP server on port 7125 + app = build_app(state) + config = uvicorn.Config(app, host=host, port=7125, log_level="warning") + server = uvicorn.Server(config) + print(f"[MOCK] HTTP (Moonraker) server listening on http://{host}:7125") + print(f"[MOCK] Control API: http://{host}:7125/mock/state") + print(f"[MOCK] Printer name: {name!r}, firmware: {firmware!r}") + print() + + try: + await server.serve() + finally: + ws_server.close() + await ws_server.wait_closed() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Mock Creality K2 Plus / Moonraker server") + parser.add_argument( + "--host", default="127.0.0.2", + help="IP address to bind to (default: 127.0.0.2). Use 127.0.0.3, etc. for more printers.", + ) + parser.add_argument( + "--name", default="Mock K2 Plus", + help="Printer display name (default: 'Mock K2 Plus')", + ) + parser.add_argument( + "--firmware", default="1.3.5.22", + help="Reported firmware version (default: 1.3.5.22)", + ) + args = parser.parse_args() + + asyncio.run(main(host=args.host, name=args.name, firmware=args.firmware)) diff --git a/mock_sshpass.sh b/mock_sshpass.sh new file mode 100755 index 0000000..b832cf1 --- /dev/null +++ b/mock_sshpass.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# mock_sshpass.sh — fake sshpass that intercepts CFSync's SSH calls and fetches +# material_box_info.json from the mock server's HTTP endpoint instead. +# +# Usage: place this on PATH *before* the real sshpass when running CFSync: +# +# PATH="$PWD:$PATH" uvicorn main:app --reload --host 0.0.0.0 --port 8000 +# +# CFSync calls: +# sshpass -p PASSWORD ssh -o ... root@HOST "cat FILE1 || cat FILE2" +# +# We extract HOST from the "root@HOST" argument and fetch: +# http://HOST:7125/mock/material_box_info +# +# The script must be named "sshpass" (copy or symlink it): +# cp mock_sshpass.sh sshpass && chmod +x sshpass + +# Parse args: skip -p PASSWORD, find "root@HOST" pattern, ignore the rest. +HOST="" +skip_next=false + +for arg in "$@"; do + if $skip_next; then + skip_next=false + continue + fi + # -p PASSWORD: skip -p and the next arg (password) + if [[ "$arg" == "-p" ]]; then + skip_next=true + continue + fi + # root@HOST + if [[ "$arg" =~ ^root@(.+)$ ]]; then + HOST="${BASH_REMATCH[1]}" + break + fi +done + +if [[ -z "$HOST" ]]; then + echo "mock_sshpass: could not determine printer host from arguments: $*" >&2 + exit 1 +fi + +URL="http://${HOST}:7125/mock/material_box_info" +response=$(curl -sf --max-time 5 "$URL" 2>/dev/null) +exit_code=$? + +if [[ $exit_code -ne 0 || -z "$response" ]]; then + echo "mock_sshpass: failed to fetch $URL (curl exit $exit_code)" >&2 + exit 1 +fi + +echo "$response" diff --git a/models/schemas.py b/models/schemas.py index dd40a9f..3717adf 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,20 @@ "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") @@ -66,59 +47,53 @@ 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 first), 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 = "" + + # Last known Moonraker print_stats.state (e.g. "printing", "paused", "complete", "") + moon_print_state: str = "" + # Webcam stream URL discovered from Moonraker (persisted so UI works before first poll) + moon_webcam_url: str = "" @field_validator("updated_at", mode="before") @classmethod @@ -144,33 +119,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) @@ -179,27 +144,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": ...}) --- @@ -208,40 +152,51 @@ 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 UiSpoolSetRemainingRequest(BaseModel): - slot: SlotId - remaining_g: float = Field(ge=0) +class SpoolmanLinkRequest(BaseModel): + printer_id: Optional[str] = None + slot: PrinterInputId + spoolman_id: int = Field(gt=0) -class UiSlotResetRequest(BaseModel): - slot: SlotId - remaining_g: float +class SpoolmanUnlinkRequest(BaseModel): + printer_id: Optional[str] = None + slot: PrinterInputId -class SpoolmanLinkRequest(BaseModel): - slot: SlotId +class JobReallocateSpoolRequest(BaseModel): + printer_id: Optional[str] = None + ended_at: float + slot: PrinterInputId spoolman_id: int = Field(gt=0) -class SpoolmanUnlinkRequest(BaseModel): - slot: SlotId +class SetSpoolmanModeRequest(BaseModel): + mode: str # "direct" | "moonraker" + +class SetSpoolmanUrlRequest(BaseModel): + url: str # empty string to disable + + +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 abe0ba8..df25e05 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 ? t('status.empty') : (isActive ? t('status.active') : t('status.ready')); + tag.textContent = meta.present === false ? 'empty' : (isActive ? 'active' : 'ready'); right.appendChild(tag); + if (meta.percent != null) { + 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,75 +141,76 @@ 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 jobKeyFromMoon(e) { - const base = (e.job_id || e.job || "").toString(); - const ts = Math.floor(Number(e.ts_end || 0) || 0); - return `${base}:${ts}`; +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 : ""; } -// --- 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 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 recentJobSlotLabel(slotId) { + const sid = String(slotId || "").toUpperCase(); + if (sid === PRINTER_SPOOL_SLOT) return "Spool"; + if (/^[1-4][A-D]$/.test(sid)) return `CFS Box ${sid[0]} · ${sid}`; + return sid || "—"; } -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 renderSpoolmanList(listEl, spools, selectedId = null) { + if (!listEl) return; + listEl.innerHTML = ''; + if (!spools.length) { + const o = document.createElement('div'); + o.className = 'spoolmanListItem muted'; + o.textContent = 'No spools found'; + 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'); } -} - -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}`); + if (!selected) { + const first = listEl.querySelector('.spoolmanListItem'); + if (first) first.classList.add('selected'); } - return r.json(); } // --- Spoolman integration --- @@ -186,12 +220,67 @@ let spoolmanConfigured = false; let spoolModalOpen = false; let spoolPrevPaused = null; let spoolSlotId = null; +let spoolPrinterId = null; +// Maps printerId → display name, populated by render() so the modal can show it +let printerDisplayNames = {}; +let historyRelinkModalOpen = false; +let historyRelinkPrevPaused = null; +let historyRelinkCtx = null; +let envChartModalOpen = false; +let envChartPrevPaused = null; +let jobHistoryPage = 0; +// Tracks which printer camera streams are currently open (survives render cycles) +const cameraOpen = new Set(); + +// Per-printer camera enabled state — persisted in localStorage +function isCameraEnabled(printerId) { + try { + const s = JSON.parse(localStorage.getItem('cameraEnabled') || '{}'); + return s[printerId] !== false; // default: enabled + } catch { return true; } +} +function setCameraEnabled(printerId, enabled) { + try { + const s = JSON.parse(localStorage.getItem('cameraEnabled') || '{}'); + s[printerId] = enabled; + localStorage.setItem('cameraEnabled', JSON.stringify(s)); + } catch {} +} +// Incremental render state — avoids full DOM teardown on every tick +const _renderedPrinters = new Map(); // pid → {block, fingerprint} +let _renderedJobsCard = null; // {el, fingerprint} | null +let _drawerOpen = false; +let _currentPage = 'dashboard'; + +function navigateTo(page) { + _currentPage = page; + const pages = ['dashboard', 'jobs', 'settings']; + for (const pg of pages) { + const el = $('page' + pg.charAt(0).toUpperCase() + pg.slice(1)); + if (el) el.style.display = pg === page ? '' : 'none'; + } + for (const item of document.querySelectorAll('.navItem[data-page]')) { + item.classList.toggle('navItem--active', item.dataset.page === page); + } + const titles = { dashboard: 'CFSync', jobs: 'Completed Jobs', settings: 'Settings' }; + const titleEl = $('printerTitle'); + if (titleEl) titleEl.textContent = titles[page] || 'CFSync'; + const subEl = $('printerSubtitle'); + if (subEl && page !== 'dashboard') subEl.textContent = ''; + // Close drawer if open + if (_drawerOpen) { + _drawerOpen = false; + const drawer = $('navDrawer'); + if (drawer) drawer.classList.remove('navDrawer--open'); + } +} function closeSpoolModal() { const m = $('spoolModal'); if (m) m.style.display = 'none'; spoolModalOpen = false; spoolSlotId = null; + spoolPrinterId = null; if (spoolPrevPaused !== null) { refreshPaused = spoolPrevPaused; spoolPrevPaused = null; @@ -199,12 +288,103 @@ 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 = ''; + envChartModalOpen = false; + if (envChartPrevPaused !== null) { + refreshPaused = envChartPrevPaused; + envChartPrevPaused = null; + applyRefreshTimer(); + } +} + +async function loadHistoryRelinkDropdown(ctx) { + const list = $('historyRelinkSelect'); + if (!list) return; + 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 : []; + renderSpoolmanList(list, spools, ctx.currentSpoolId || null); + if (applyBtn) applyBtn.disabled = !spools.length; + } catch (e) { + 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; @@ -213,28 +393,16 @@ 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 = t('spool.stats_full', {remaining: fmtG(remG), used: fmtG(usedG), total: fmtG(totalG != null ? totalG : 0)}); - } else if (remG != null) { - st.textContent = t('spool.stats_partial', {remaining: fmtG(remG)}); + if (title) { + const printerName = printerId ? (printerDisplayNames[printerId] || printerId) : null; + const slotLabel = slotTitle(slotId); + title.textContent = printerName ? `${printerName} · ${slotLabel}` : slotLabel; + } + if (sub) { + if (meta.present === false) { + sub.textContent = "empty"; } else { - st.textContent = t('spool.stats_none'); + sub.textContent = `${meta.material || '—'} · ${(meta.color || '').toUpperCase() || '—'}`; } } @@ -243,26 +411,41 @@ function openSpoolModal(slotId, meta) { if (smSec) { if (spoolmanConfigured) { smSec.style.display = ''; - const badge = $('spoolmanBadge'); + const bdg = $('spoolmanBadge'); const notLinked = $('spoolmanNotLinked'); const linked = $('spoolmanLinked'); const info = $('spoolmanInfo'); const smId = meta.spoolman_id; if (smId) { - if (badge) { badge.textContent = t('spoolman.linked'); badge.classList.remove('muted'); badge.classList.add('ok'); } + if (bdg) { bdg.textContent = 'linked'; bdg.classList.remove('muted'); bdg.classList.add('ok'); } if (notLinked) notLinked.style.display = 'none'; if (linked) linked.style.display = 'flex'; - if (info) info.textContent = t('spoolman.linked_info', { - id: String(smId), - vendor: meta.manufacturer || meta.vendor || '', - name: meta.name || '', - remaining: fmtG(meta.spool_remaining_g != null ? meta.spool_remaining_g : meta.remaining_g), - }); + if (info) { + info.textContent = '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 (badge) { badge.textContent = t('spoolman.not_linked'); badge.classList.add('muted'); badge.classList.remove('ok'); } + if (bdg) { bdg.textContent = 'not linked'; bdg.classList.add('muted'); bdg.classList.remove('ok'); } if (notLinked) notLinked.style.display = 'flex'; if (linked) linked.style.display = 'none'; - loadSpoolmanDropdown(slotId); + loadSpoolmanDropdown(slotId, printerId); } } else { smSec.style.display = 'none'; @@ -272,53 +455,27 @@ function openSpoolModal(slotId, meta) { m.style.display = 'block'; } -async function loadSpoolmanDropdown(slotId) { - const sel = $('spoolmanSelect'); - if (!sel) return; - sel.innerHTML = ''; - const ph = document.createElement('option'); - ph.value = ''; - ph.textContent = t('spoolman.loading'); - sel.appendChild(ph); +async function loadSpoolmanDropdown(slotId, printerId) { + const list = $('spoolmanSelect'); + if (!list) return; + 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)}`, { cache: 'no-store' }); + 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 = data.spools || []; - sel.innerHTML = ''; - - if (!spools.length) { - const o = document.createElement('option'); - o.value = ''; - o.textContent = t('spoolman.no_spools'); - sel.appendChild(o); - return; - } - - const def = document.createElement('option'); - def.value = ''; - def.textContent = t('spoolman.select_ph'); - sel.appendChild(def); - - for (const sp of spools) { - const o = document.createElement('option'); - o.value = String(sp.id); - o.textContent = t('spoolman.option_label', { - id: String(sp.id), - vendor: sp.vendor || '', - name: sp.filament_name || '', - material: sp.material || '', - remaining: sp.remaining_weight != null ? fmtG(sp.remaining_weight) : '?', - }); - sel.appendChild(o); - } + const spools = Array.isArray(data.spools) ? data.spools : []; + renderSpoolmanList(list, spools, null); } catch (e) { - sel.innerHTML = ''; - const o = document.createElement('option'); - o.value = ''; - o.textContent = t('spoolman.error', { msg: e.message || String(e) }); - sel.appendChild(o); + list.innerHTML = ''; + const o = document.createElement('div'); + o.className = 'spoolmanListItem muted'; + o.textContent = `Spoolman error: ${e.message || String(e)}`; + list.appendChild(o); } } @@ -347,37 +504,6 @@ function initSpoolModal() { } }); - const saveStart = $('spoolSaveStart'); - const saveRemain = $('spoolSaveRemain'); - - if (saveStart) { - saveStart.onclick = async (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - if (!spoolSlotId) return; - const v = Number(($('spoolStart') || {}).value || 0); - if (!Number.isFinite(v) || v <= 0) return; - // Rollwechsel: new epoch + new reference - await postJson('/api/ui/spool/set_start', { slot: spoolSlotId, start_g: v }); - closeSpoolModal(); - await tick(); - }; - } - - if (saveRemain) { - saveRemain.onclick = async (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - if (!spoolSlotId) return; - const v = Number(($('spoolRemain') || {}).value || 0); - if (!Number.isFinite(v) || v < 0) return; - // Übernehmen: set measured remaining as reference (no epoch reset) - await postJson('/api/ui/spool/set_remaining', { slot: spoolSlotId, remaining_g: v }); - closeSpoolModal(); - await tick(); - }; - } - // --- Spoolman button handlers --- const smLink = $('spoolmanLink'); const smUnlink = $('spoolmanUnlink'); @@ -388,10 +514,11 @@ function initSpoolModal() { ev.preventDefault(); ev.stopPropagation(); if (!spoolSlotId) return; - const sel = $('spoolmanSelect'); - const id = sel ? Number(sel.value) : 0; + 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', { slot: spoolSlotId, spoolman_id: id }); + await postJson('/api/ui/spoolman/link', { printer_id: spoolPrinterId, slot: spoolSlotId, spoolman_id: id }); closeSpoolModal(); await tick(); }; @@ -402,8 +529,22 @@ function initSpoolModal() { ev.preventDefault(); ev.stopPropagation(); if (!spoolSlotId) return; - await postJson('/api/ui/spoolman/unlink', { slot: spoolSlotId }); - closeSpoolModal(); + const unlinkSlot = spoolSlotId; + const unlinkPrinter = spoolPrinterId; + try { + await postJson('/api/ui/spoolman/unlink', { printer_id: unlinkPrinter, slot: unlinkSlot }); + } catch (e) { + alert(`Unlink failed: ${e.message || e}`); + return; + } + // Switch modal to "not linked" state without closing + const bdg = $('spoolmanBadge'); + const notLinked = $('spoolmanNotLinked'); + const linked = $('spoolmanLinked'); + if (bdg) { bdg.textContent = 'not linked'; bdg.classList.add('muted'); bdg.classList.remove('ok'); } + if (linked) linked.style.display = 'none'; + if (notLinked) notLinked.style.display = 'flex'; + await loadSpoolmanDropdown(unlinkSlot, unlinkPrinter); await tick(); }; } @@ -413,524 +554,1473 @@ function initSpoolModal() { ev.preventDefault(); ev.stopPropagation(); if (!spoolSlotId) return; - // Re-link to re-import remaining_weight from Spoolman + // Re-fetch spool detail from Spoolman const info = $('spoolmanInfo'); - // Get spoolman_id from current state try { - const r = await fetch('/api/ui/state', { cache: 'no-store' }); - const j = await r.json(); - const st = j.result || j; - const slotData = (st.slots || {})[spoolSlotId] || {}; - const smId = slotData.spoolman_id; - if (!smId) return; - await postJson('/api/ui/spoolman/link', { slot: spoolSlotId, spoolman_id: smId }); - closeSpoolModal(); - await tick(); + if (info) info.textContent = '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 = t('spoolman.error', { msg: e.message || String(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 = t('moon.empty'); - wrap.appendChild(empty); - return; - } + document.addEventListener('keydown', (ev) => { + if (!historyRelinkModalOpen) return; + if (ev.key === 'Escape') { + ev.preventDefault(); + closeHistoryRelinkModal(); + } + }); - const slotIds = buildSlotIds(connectedBoxes); - const allocStore = (state.moonraker_allocations && typeof state.moonraker_allocations === "object") ? state.moonraker_allocations : {}; + 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'; + } + }; + } +} - 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 envMetricMeta(metricKey) { + if (metricKey === 'humidity_pct') { + return { title: 'Humidity', unit: '%', lineClass: 'envLineHum', areaClass: 'envAreaHum' }; + } + return { title: 'Temperature', unit: '°C', lineClass: 'envLineTemp', areaClass: 'envAreaTemp' }; +} - const det = document.createElement("details"); - det.className = "moonEntry"; - det.dataset.key = key; +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 sum = document.createElement("summary"); - const row = document.createElement("div"); - row.className = "moonRow"; +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); + const maxPoints = 360; + if (out.length <= maxPoints) return out; + const step = Math.ceil(out.length / maxPoints); + const reduced = []; + for (let i = 0; i < out.length; i += step) reduced.push(out[i]); + if (reduced[reduced.length - 1] !== out[out.length - 1]) reduced.push(out[out.length - 1]); + return reduced; +} - const job = document.createElement("div"); - job.className = "moonJob"; - job.textContent = e.job || t('history.no_name'); +function renderEnvChart(metricKey, history) { + const body = $('envChartBody'); + const meta = $('envChartMeta'); + if (!body) return; + body.innerHTML = ''; + const mm = envMetricMeta(metricKey); + const points = envHistorySeries(history, metricKey); + + 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; + } - 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) : "—"); + const first = points[0]; + const last = points[points.length - 1]; + if (meta) { + meta.textContent = `Samples: ${points.length} · ${fmtTs(first.ts)} → ${fmtTs(last.ts)} · Latest: ${fmtEnvValue(last.value, metricKey)}`; + } - row.appendChild(job); - row.appendChild(nums); - sum.appendChild(row); - det.appendChild(sum); + 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 sub = document.createElement("div"); - sub.className = "moonSub"; + 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); +} - const when = document.createElement("span"); - when.textContent = "🕒 " + fmtTs(e.ts_end || e.ts_start); - sub.appendChild(when); +function openEnvChartModal(ctx) { + const m = $('envChartModal'); + if (!m) return; + envChartModalOpen = true; - const st = document.createElement("span"); - st.textContent = "📌 " + String(e.status || ""); - sub.appendChild(st); + if (envChartPrevPaused === null) envChartPrevPaused = refreshPaused; + refreshPaused = true; + applyRefreshTimer(); - if (e.filament_type) { - const ft = document.createElement("span"); - ft.textContent = "🧵 " + String(e.filament_type); - sub.appendChild(ft); - } + 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$/, ''); + } - // --- 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 ? t('assign.title_existing') : t('assign.title_new'); - assign.appendChild(assignTitle); - - // When already assigned: keep UI clean, allow optional edit. - const editBtn = document.createElement("button"); - editBtn.className = "btn mini"; - editBtn.type = "button"; - editBtn.textContent = existing ? t('assign.btn_edit') : ""; - 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 = t('assign.select_default'); - 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; - }; + renderEnvChart(ctx.metricKey, ctx.history); + m.style.display = 'block'; +} - 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: t('assign.total'), g: Number(gTotal) }); +function initEnvChartModal() { + const m = $('envChartModal'); + if (!m) return; + const closeBtn = $('envChartClose'); + const back = $('envChartBackdrop'); + 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(); } + }); +} - if (!perColor.length) { - const note = document.createElement("div"); - note.className = "tag muted"; - note.textContent = t('moon.no_consumption'); - 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; - } - - assign.appendChild(rows); - - const actions = document.createElement("div"); - actions.className = "assignActions"; - const btn = document.createElement("button"); - btn.className = "btn"; - btn.textContent = existing ? t('assign.btn_update') : t('assign.btn_assign'); - 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(t('assign.alert_select')); - return; - } - const payload = { job_key: key, job: e.job || "", ts: Number(e.ts_end || e.ts_start || 0), alloc_g: alloc }; - await postJson("/api/ui/moonraker/allocate", payload); - // Force refresh - await tick(); - } catch (err) { - alert(t('assign.error_save') + (err && err.message ? err.message : String(err))); - } - }; - actions.appendChild(btn); - - if (existing && typeof existing === "object") { - const info = document.createElement("div"); - info.className = "tag"; - const parts = []; - for (const [sid, g] of Object.entries(existing)) parts.push(`${sid}: ${fmtG(g)}`); - info.textContent = t('assign.current') + parts.join(" · "); - actions.appendChild(info); - } - assign.appendChild(actions); - } - sub.appendChild(assign); +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'; +} - det.appendChild(sub); - wrap.appendChild(det); +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"); +function renderCfsStats(state, wrap) { if (!wrap) return; - wrap.innerHTML = ""; - - const history = state.slot_history || {}; - const active = state.cfs_active_slot || state.active_slot || null; - - const slotIds = buildSlotIds(connectedBoxes); - - const metaFor = (sid) => { - const m = (slots && slots[sid]) ? slots[sid] : {}; - const local = (state.slots && state.slots[sid]) ? state.slots[sid] : {}; - return { - present: (m.present ?? local.present ?? true), - material: ((m.material ?? local.material) || "").toString().toUpperCase(), - color: ((m.color ?? m.color_hex ?? local.color ?? local.color_hex) || "").toString().toLowerCase(), - remaining_g: (local.remaining_g ?? null), - spool_remaining_g: (local.spool_remaining_g ?? null), - spool_used_g: (local.spool_used_g ?? null), - spool_consumed_g: (local.spool_consumed_g ?? null), - }; - }; - - for (const sid of slotIds) { - const m = metaFor(sid); - const epoch = Number(((state.slots || {})[sid] || {}).spool_epoch || 0); - const rawEntries = Array.isArray(history[sid]) ? history[sid] : []; - const entries = rawEntries.filter(e => Number((e || {}).epoch || 0) === epoch).slice(0,4); + 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 card = document.createElement("div"); - card.className = "histSlot"; + for (const b of boxIds) { + const slotIds = ['A', 'B', 'C', 'D'].map(l => `${b}${l}`); - 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 ? t('history.active_suffix') : ""); - 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 = t('history.no_data'); - list.appendChild(empty); - } else { - for (const e of entries) { - const det = document.createElement("details"); - det.className = "histEntry"; - det.dataset.key = `${sid}:${String(e.ts || '')}:${String(e.job || '')}`; - - const sum = document.createElement("summary"); - const row = document.createElement("div"); - row.className = "histRow"; - - const job = document.createElement("div"); - job.className = "histJob"; - job.textContent = (e.job || t('history.no_name')); - - const nums = document.createElement("div"); - nums.className = "histNums"; - const mmTxt = Number(e.used_mm || 0) > 0 ? ` (${fmtMm(e.used_mm)})` : ""; - nums.textContent = `${fmtG(e.used_g)}${mmTxt}`; - - row.appendChild(job); - row.appendChild(nums); - sum.appendChild(row); - det.appendChild(sum); - - const sub = document.createElement("div"); - sub.className = "histSub"; - const when = document.createElement("span"); - when.textContent = "🕒 " + fmtTs(e.ts); - const res = document.createElement("span"); - res.textContent = "✅ " + String(e.result || ""); - const mat = document.createElement("span"); - mat.textContent = "🧵 " + (m.material || "—") + (m.color ? " " + m.color.toUpperCase() : ""); - sub.appendChild(when); - sub.appendChild(mat); - if (e.result) sub.appendChild(res); - det.appendChild(sub); - - list.appendChild(det); - } + const 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; +} - const printerOk = !!state.printer_connected; - badge(printerBadge, printerOk ? t('badge.printer_ok') : t('badge.printer_off'), printerOk ? "ok" : "bad"); - if (!printerOk && state.printer_last_error) { - printerBadge.textContent += " (" + state.printer_last_error + ")"; +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 — light 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"; + pBadge.dataset.live = "printer-badge"; + const cfsBadge = document.createElement("div"); + cfsBadge.className = "badge"; + cfsBadge.dataset.live = "cfs-badge"; + const printerOk = !!state.printer_connected; + const _narrow = window.innerWidth < 480; + badge(pBadge, printerOk ? (_narrow ? "Connected" : "Printer: connected") : (_narrow ? "Offline" : "Printer: disconnected"), printerOk ? "ok" : "bad"); + if (!printerOk && state.printer_last_error && !_narrow) { + pBadge.textContent += " (" + state.printer_last_error + ")"; + } const cfsOk = !!state.cfs_connected; badge( cfsBadge, - cfsOk ? t('badge.cfs_ok', {ts: fmtTs(state.cfs_last_update)}) : t('badge.cfs_off'), + cfsOk ? (_narrow ? "CFS ✓" : `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); + // CFS usage stats — belongs with the boxes, not the printer hardware + const statsCard = document.createElement("section"); + statsCard.className = "card"; + statsCard.style.marginTop = "16px"; + const statsHead = document.createElement("div"); + statsHead.className = "cardHead"; + const statsTitle = document.createElement("div"); + statsTitle.className = "cardTitle"; + statsTitle.textContent = "Filament usage"; + 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); + leftCol.appendChild(activeCard); + leftCol.appendChild(statsCard); + + const rightCol = document.createElement("aside"); + rightCol.className = "rightCol"; + rightCol.appendChild(renderPrinterStatusCard(state)); + rightCol.appendChild(renderCameraCard(state, printerId)); + + 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 moonPrinting = ['printing', 'paused'].includes(state.moon_print_state || ''); + const spPresentNow = !!(slots[PRINTER_SPOOL_SLOT] || localSlots[PRINTER_SPOOL_SLOT] || {}).present; + // Only treat SP as active once actual extrusion has started (filament_used > 0). + // This prevents the spool holder showing as "active" during homing, bed meshing, + // and startup sequences where no filament is extruded yet. + const spExtruding = (state.moon_filament_used_mm || 0) > 0; + const active = state.cfs_active_slot || (moonPrinting && spPresentNow && spExtruding ? PRINTER_SPOOL_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: (local.name ?? ''), - manufacturer: (local.manufacturer ?? local.vendor ?? ''), + 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 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); + }); + + return pod; + } + function makeBoxCard(boxNum) { - const card = document.createElement("div"); + const card = document.createElement("section"); card.className = "card"; + // Card head: title + active slot badge + env chips const head = document.createElement("div"); head.className = "cardHead"; - const title = document.createElement("div"); - title.className = "cardTitle"; - title.textContent = `Box ${boxNum}`; + const titleEl = document.createElement("div"); + titleEl.className = "cardTitle"; + titleEl.textContent = `Box ${boxNum}`; + head.appendChild(titleEl); const meta = document.createElement("div"); meta.className = "cardMeta"; + // Active slot badge — show which slot letter is active in this box + const activeSlotLetter = (active && active[0] === String(boxNum)) ? active[1] : null; + if (activeSlotLetter) { + const activeBadge = document.createElement("span"); + activeBadge.className = "tag ok"; + activeBadge.textContent = `Slot ${activeSlotLetter} active`; + meta.appendChild(activeBadge); + } + + // Env sensor chips 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, + }); + }); + meta.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, + }); + }); + meta.appendChild(chip); } - head.appendChild(title); - if (meta.childNodes.length) head.appendChild(meta); + head.appendChild(meta); card.appendChild(head); + // Horizontal row of 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; + } + + function makeSpoolInputCard() { + const card = document.createElement("section"); + card.className = "card"; + + const head = document.createElement("div"); + head.className = "cardHead"; + const titleEl = document.createElement("div"); + titleEl.className = "cardTitle"; + titleEl.textContent = "Spool"; + head.appendChild(titleEl); + + if (active === PRINTER_SPOOL_SLOT) { + const meta = document.createElement("div"); + meta.className = "cardMeta"; + const activeBadge = document.createElement("span"); + activeBadge.className = "tag ok"; + activeBadge.textContent = "Active"; + meta.appendChild(activeBadge); + head.appendChild(meta); + } + card.appendChild(head); + + 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)); + card.appendChild(slotsWrap); + return card; } 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; +} + +// Returns a fingerprint string covering elements that require full DOM rebuild. +// Things that change every tick (temps, progress) are intentionally excluded. +function _printerStructFingerprint(st) { + const cfsSlots = st.cfs_slots || {}; + const spMeta = cfsSlots['SP'] || {}; + const moonPrinting = ['printing', 'paused'].includes(st.moon_print_state || ''); + const spExtruding = (st.moon_filament_used_mm || 0) > 0; + const effectiveActive = st.cfs_active_slot || (moonPrinting && spMeta.present && spExtruding ? 'SP' : ''); + const slotSig = Object.keys(cfsSlots) + .filter(k => /^[1-4][A-D]$/.test(k) || k === 'SP') + .sort() + .map(k => { const v = cfsSlots[k] || {}; return `${k}:${v.state ?? ''}:${!!v.present}:${v.selected ?? 0}`; }) + .join('|'); + const localSlots = st.slots || {}; + const localSig = Object.keys(localSlots).sort() + .map(k => { const s = localSlots[k] || {}; return `${k}:${s.spoolman_id ?? ''}:${s.material ?? ''}:${s.color ?? s.color_hex ?? ''}:${s.name ?? ''}`; }) + .join('|'); + return `${effectiveActive}:${slotSig}:${JSON.stringify(cfsSlots._boxes || {})}:${localSig}`; +} + +function _jobsFingerprint(printers) { + return printers.map(p => { + const hist = (p.state || p).job_history || []; + return hist.map(j => j.ended_at || j.started_at || 0).join(','); + }).join('|'); +} + +// Patches only live-changing data (temps, progress, badges) into an existing printer block. +function _patchPrinterBlock(block, st) { + const live = key => block.querySelector(`[data-live="${key}"]`); + const setText = (key, text) => { const el = live(key); if (el && el.textContent !== text) el.textContent = text; }; + + const pBadge = live('printer-badge'); + if (pBadge) { + const ok = !!st.printer_connected; + const _narrow = window.innerWidth < 480; + let text = ok ? (_narrow ? "Connected" : "Printer: connected") : (_narrow ? "Offline" : "Printer: disconnected"); + if (!ok && st.printer_last_error && !_narrow) text += ` (${st.printer_last_error})`; + if (pBadge.textContent !== text) pBadge.textContent = text; + pBadge.className = 'badge ' + (ok ? 'ok' : 'bad'); + } + const cfsBadge = live('cfs-badge'); + if (cfsBadge) { + const ok = !!st.cfs_connected; + const _narrow = window.innerWidth < 480; + const text = ok ? (_narrow ? "CFS ✓" : `CFS: detected · ${fmtTs(st.cfs_last_update)}`) : "CFS: —"; + if (cfsBadge.textContent !== text) cfsBadge.textContent = text; + cfsBadge.className = 'badge ' + (ok ? 'ok' : 'warn'); + } + + const ps = st.moon_print_state || ''; + setText('print-state', ps ? ps.charAt(0).toUpperCase() + ps.slice(1) : 'Idle'); + + function fmtTemp(actual, target) { + const a = actual > 0 ? actual.toFixed(1) + '°' : '—'; + const t = target > 0 ? target.toFixed(0) + '°' : ''; + return t ? `${a} / ${t}` : a; + } + function patchTemp(key, actual, target) { + const el = live(key); + if (!el) return; + const text = fmtTemp(actual, target); + if (el.textContent !== text) el.textContent = text; + const at = target > 0 && Math.abs(actual - target) < 3; + el.className = 'tempVal' + (at ? ' atTemp' : ''); + } + patchTemp('nozzle-val', st.moon_nozzle_temp || 0, st.moon_nozzle_target || 0); + patchTemp('bed-val', st.moon_bed_temp || 0, st.moon_bed_target || 0); + + const section = live('progress-section'); + if (section) { + const printing = ['printing', 'paused'].includes(ps); + const progress = Number(st.moon_progress || 0); + const show = printing || progress > 0; + section.style.display = show ? '' : 'none'; + if (show) { + const bar = live('progress-bar'); + if (bar) { const w = (progress * 100).toFixed(1) + '%'; if (bar.style.width !== w) bar.style.width = w; } + const filename = st.moon_print_filename || ''; + const fnEl = live('filename'); + if (fnEl) { + const t = filename.replace(/\.gcode$/i, ''); + if (fnEl.textContent !== t) { fnEl.textContent = t; fnEl.title = filename; } + fnEl.style.display = filename ? '' : 'none'; + } + const durationS = Number(st.moon_print_duration_s || 0); + const pct = (progress * 100).toFixed(0) + '%'; + let timeStr = ''; + if (durationS > 0) { + const h = Math.floor(durationS / 3600), m = Math.floor((durationS % 3600) / 60); + timeStr = h > 0 ? `${h}h ${m}m elapsed` : `${m}m elapsed`; } - activeLive.style.display = 'flex'; + setText('progress-meta', timeStr ? `${pct} · ${timeStr}` : pct); } + } +} + +function renderPrinterStatusCard(state) { + const card = document.createElement("section"); + card.className = "card printerStatusCard"; + + const head = document.createElement("div"); + head.className = "cardHead"; + const title = document.createElement("div"); + title.className = "cardTitle"; + title.textContent = "Printer"; + const stateTag = document.createElement("div"); + stateTag.className = "cardMeta"; + stateTag.dataset.live = "print-state"; + const ps = state.moon_print_state || ""; + stateTag.textContent = ps ? ps.charAt(0).toUpperCase() + ps.slice(1) : "Idle"; + head.appendChild(title); + head.appendChild(stateTag); + card.appendChild(head); + + const body = document.createElement("div"); + body.className = "printerStatusBody"; + + // Temperatures row + const tempsRow = document.createElement("div"); + tempsRow.className = "printerTemps"; + + function tempWidget(label, liveKey, actual, target) { + const w = document.createElement("div"); + w.className = "tempWidget"; + const lbl = document.createElement("div"); + lbl.className = "tempLabel"; + lbl.textContent = label; + const val = document.createElement("div"); + val.className = "tempVal"; + val.dataset.live = liveKey; + const actualStr = actual > 0 ? actual.toFixed(1) + "°" : "—"; + const targetStr = target > 0 ? target.toFixed(0) + "°" : ""; + val.textContent = targetStr ? `${actualStr} / ${targetStr}` : actualStr; + if (target > 0 && Math.abs(actual - target) < 3) val.classList.add("atTemp"); + w.appendChild(lbl); + w.appendChild(val); + return w; + } + tempsRow.appendChild(tempWidget("Nozzle", "nozzle-val", state.moon_nozzle_temp || 0, state.moon_nozzle_target || 0)); + tempsRow.appendChild(tempWidget("Bed", "bed-val", state.moon_bed_temp || 0, state.moon_bed_target || 0)); + body.appendChild(tempsRow); + + // Progress section — always in DOM, hidden when not printing so it can be patched in-place + const printing = ['printing', 'paused'].includes(ps); + const progress = Number(state.moon_progress || 0); + const filename = state.moon_print_filename || ""; + const durationS = Number(state.moon_print_duration_s || 0); + + const progressSection = document.createElement("div"); + progressSection.dataset.live = "progress-section"; + progressSection.style.display = (printing || progress > 0) ? '' : 'none'; + + const fnRow = document.createElement("div"); + fnRow.className = "printerFilename"; + fnRow.dataset.live = "filename"; + fnRow.textContent = filename.replace(/\.gcode$/i, ""); + fnRow.title = filename; + fnRow.style.display = filename ? '' : 'none'; + progressSection.appendChild(fnRow); + + const barWrap = document.createElement("div"); + barWrap.className = "progressBarWrap"; + const bar = document.createElement("div"); + bar.className = "progressBar"; + bar.dataset.live = "progress-bar"; + bar.style.width = (progress * 100).toFixed(1) + "%"; + barWrap.appendChild(bar); + progressSection.appendChild(barWrap); + + const progressMeta = document.createElement("div"); + progressMeta.className = "progressMeta"; + progressMeta.dataset.live = "progress-meta"; + const pct = (progress * 100).toFixed(0) + "%"; + let timeStr = ""; + if (durationS > 0) { + const h = Math.floor(durationS / 3600); + const m = Math.floor((durationS % 3600) / 60); + timeStr = h > 0 ? `${h}h ${m}m elapsed` : `${m}m elapsed`; + } + progressMeta.textContent = timeStr ? `${pct} · ${timeStr}` : pct; + progressSection.appendChild(progressMeta); + + body.appendChild(progressSection); + + card.appendChild(body); + return card; +} + +function renderCameraCard(state, printerId) { + const webcamUrl = state.moon_webcam_url || ""; + const card = document.createElement("section"); + card.className = "card cameraCard"; + + const head = document.createElement("div"); + head.className = "cardHead"; + const title = document.createElement("div"); + title.className = "cardTitle"; + title.textContent = "Camera"; + const toggleBtn = document.createElement("button"); + toggleBtn.className = "btn mini"; + head.appendChild(title); + head.appendChild(toggleBtn); + card.appendChild(head); + + const streamWrap = document.createElement("div"); + streamWrap.className = "cameraWrap"; + + if (!webcamUrl) { + toggleBtn.style.display = "none"; + const hint = document.createElement("div"); + hint.className = "cameraPlaceholder"; + hint.textContent = "No webcam configured in Moonraker."; + streamWrap.appendChild(hint); + } else if (!isCameraEnabled(printerId)) { + // Camera disabled via settings — compact state, no 16:9 space reserved + toggleBtn.style.display = "none"; + const disabledHint = document.createElement("div"); + disabledHint.className = "cameraDisabled"; + disabledHint.textContent = "Camera disabled in Settings."; + streamWrap.appendChild(disabledHint); } else { - $("activeMeta").textContent = "—"; + const isOpen = cameraOpen.has(printerId); + + // Placeholder — always in DOM so layout height never changes on toggle + const placeholder = document.createElement("div"); + placeholder.className = "cameraPlaceholder"; + placeholder.textContent = "Camera hidden"; + placeholder.style.display = isOpen ? "none" : ""; + + const img = document.createElement("img"); + img.className = "cameraFeed"; + img.alt = "Camera feed"; + img.style.display = isOpen ? "" : "none"; + if (isOpen) img.src = webcamUrl; + + toggleBtn.textContent = isOpen ? "Hide" : "Show"; + + toggleBtn.addEventListener("click", () => { + const showing = img.style.display !== "none"; + if (showing) { + img.src = ""; + img.style.display = "none"; + placeholder.style.display = ""; + toggleBtn.textContent = "Show"; + cameraOpen.delete(printerId); + } else { + img.src = webcamUrl; + img.style.display = ""; + placeholder.style.display = "none"; + toggleBtn.textContent = "Hide"; + cameraOpen.add(printerId); + } + }); + + streamWrap.appendChild(placeholder); + streamWrap.appendChild(img); + } + + card.appendChild(streamWrap); + return card; +} + +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 || ""), + spools, + totalMeters, + totalGrams, + }); + } + } + + rows.sort((a, b) => (b.endedAt || 0) - (a.endedAt || 0)); + + const PAGE_SIZE = 10; + const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); + if (jobHistoryPage >= totalPages) jobHistoryPage = totalPages - 1; + const pageStart = jobHistoryPage * PAGE_SIZE; + const top = rows.slice(pageStart, pageStart + PAGE_SIZE); + + 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 = rows.length > PAGE_SIZE + ? `Jobs ${pageStart + 1}–${Math.min(pageStart + PAGE_SIZE, rows.length)} of ${rows.length}` + : `${rows.length} completed job${rows.length === 1 ? "" : "s"}`; + titleWrap.appendChild(title); + titleWrap.appendChild(meta); + head.appendChild(titleWrap); + 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"; + sub.textContent = `Start: ${fmtTs(j.startedAt)} · End: ${fmtTs(j.endedAt)} · Print Time: ${fmtDuration(j.startedAt, j.endedAt)}`; + 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); + label.textContent = `${recentJobSlotLabel(s.slot)} · ${spoolId > 0 ? "#" + spoolId : "not linked"}`; + const spoolMeta = document.createElement("div"); + spoolMeta.className = "moonSpoolMeta"; + spoolMeta.textContent = `${(Number(s.meters || 0)).toFixed(2)} m · ${fmtG(Number(s.grams || 0))}`; + textWrap.appendChild(label); + textWrap.appendChild(spoolMeta); + 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), + }); + }; + spoolRow.appendChild(btn); + spoolList.appendChild(spoolRow); + } + } + entry.appendChild(spoolList); + + list.appendChild(entry); + } + + if (totalPages > 1) { + const pager = document.createElement("div"); + pager.className = "jobHistoryPager"; + const prev = document.createElement("button"); + prev.className = "btn mini"; + prev.textContent = "← Prev"; + prev.disabled = jobHistoryPage === 0; + prev.onclick = () => { jobHistoryPage--; tick(); }; + const pageLabel = document.createElement("span"); + pageLabel.className = "jobHistoryPageLabel"; + pageLabel.textContent = `Page ${jobHistoryPage + 1} / ${totalPages}`; + const next = document.createElement("button"); + next.className = "btn mini"; + next.textContent = "Next →"; + next.disabled = jobHistoryPage >= totalPages - 1; + next.onclick = () => { jobHistoryPage++; tick(); }; + pager.appendChild(prev); + pager.appendChild(pageLabel); + pager.appendChild(next); + list.appendChild(pager); + } + + return block; +} + +function renderCameraSettings(printers) { + const wrap = $('settingsCameraRows'); + if (!wrap) return; + wrap.innerHTML = ''; + + const withCam = printers.filter(p => (p.state || p).moon_webcam_url); + if (!withCam.length) { + const none = document.createElement('div'); + none.className = 'settingsHint'; + none.style.padding = '10px 0'; + none.textContent = 'No webcam URLs detected from Moonraker.'; + wrap.appendChild(none); + return; + } + + for (const p of withCam) { + const pid = p.id || p.printer_id || p.host || ''; + const st = p.state || p; + const name = st.printer_name || pid || 'Printer'; + const enabled = isCameraEnabled(pid); + + const row = document.createElement('div'); + row.className = 'settingsItem settingsCameraRow'; + + const lbl = document.createElement('div'); + lbl.className = 'settingsItemLabel'; + lbl.textContent = name; + + const switchLabel = document.createElement('label'); + switchLabel.className = 'mdSwitch'; + switchLabel.title = enabled ? 'Disable camera' : 'Enable camera'; + + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = enabled; + cb.addEventListener('change', () => { + setCameraEnabled(pid, cb.checked); + // Force a full rebuild of the affected printer block + const existing = _renderedPrinters.get(pid); + if (existing) { + existing.fingerprint = null; // invalidate so next tick rebuilds + } + tick(); + }); + + const track = document.createElement('span'); + track.className = 'mdSwitchTrack'; + + switchLabel.appendChild(cb); + switchLabel.appendChild(track); + + row.appendChild(lbl); + row.appendChild(switchLabel); + wrap.appendChild(row); + } +} + +function render(ui) { + const printers = (ui && ui.printers) ? ui.printers : []; + + // Spoolman sync mode radio buttons (inside settings modal) + // Use first printer's state to get the mode, or fall back to top-level + const firstState = printers.length ? (printers[0].state || printers[0]) : {}; + const currentMode = (ui && ui.spoolman_mode) || firstState.spoolman_mode || "direct"; + document.querySelectorAll('input[name="spoolmanMode"]').forEach(r => { + r.checked = (r.value === currentMode); + r.onchange = async () => { + await postJson("/api/ui/set_spoolman_mode", { mode: r.value }); + }; + }); + + // Show/hide Spoolman sync mode section + const smSection = $("settingsSpoolmanSection"); + if (smSection) smSection.style.display = spoolmanConfigured ? '' : 'none'; + + // Populate Spoolman URL input — skip if the field is actively focused + const smUrlInput = $("settingsSpoolmanUrl"); + if (smUrlInput && document.activeElement !== smUrlInput) { + smUrlInput.value = (ui && ui.spoolman_url) || ''; + } + + // Spoolman external links (topbar and settings modal) + for (const linkId of ["spoolmanExtLink", "spoolmanExtLinkSettings"]) { + const smExtLink = $(linkId); + if (smExtLink) { + if (ui && ui.spoolman_url) { + smExtLink.href = ui.spoolman_url; + smExtLink.style.display = ''; + } else { + smExtLink.style.display = 'none'; + } + } + } + + // Update heading / title (only on dashboard page; other pages set their own title) + document.title = printers.length ? `CFSync · ${printers.length} printers` : "CFSync"; + if (_currentPage === 'dashboard') { + const printerTitle = $("printerTitle"); + if (printerTitle) printerTitle.textContent = "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 cfsOk = printers.filter(p => (p.state || p).cfs_connected).length; + + const _topNarrow = window.innerWidth < 480; + if (printerBadge) { + if (!total) { + badge(printerBadge, "Printers: —", "warn"); + } else { + const cls = connected === total ? "ok" : (connected > 0 ? "warn" : "bad"); + badge(printerBadge, _topNarrow ? `${connected}/${total}` : `Printers: ${connected}/${total} online`, cls); + } + } + if (cfsBadge) { + if (!total) { + badge(cfsBadge, "CFS: —", "warn"); + } else { + badge(cfsBadge, _topNarrow ? `CFS: ${cfsOk}` : `CFS: ${cfsOk} detected`, cfsOk > 0 ? "ok" : "warn"); + } + } + + const wrap = $("printersWrap"); + if (!wrap) return; + + if (!printers.length) { + wrap.innerHTML = ""; + _renderedPrinters.clear(); + _renderedJobsCard = null; + const jobsWrap = $('jobsPageWrap'); + if (jobsWrap) jobsWrap.innerHTML = ''; + 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; + } + + // Remove stale empty state if present + const emptyEl = wrap.querySelector('.emptyState'); + if (emptyEl) emptyEl.remove(); + + printerDisplayNames = {}; + const currentPids = new Set(printers.map(p => p.id || p.printer_id || p.host || '')); + + // Remove blocks for printers no longer in the list + for (const [pid] of _renderedPrinters) { + if (!currentPids.has(pid)) { + _renderedPrinters.get(pid).block.remove(); + _renderedPrinters.delete(pid); + } + } + + for (const p of printers) { + const pid = p.id || p.printer_id || p.host || ""; + const st = p.state || p; + printerDisplayNames[pid] = st.printer_name || pid; + + const fingerprint = _printerStructFingerprint(st); + const existing = _renderedPrinters.get(pid); + + if (!existing || !existing.block.isConnected) { + // New printer — append to main content + const block = renderPrinter(pid, st); + wrap.appendChild(block); + _renderedPrinters.set(pid, { block, fingerprint }); + } else if (existing.fingerprint !== fingerprint) { + // Structure changed — full rebuild for this printer only + const block = renderPrinter(pid, st); + wrap.replaceChild(block, existing.block); + _renderedPrinters.set(pid, { block, fingerprint }); + } else { + // No structural change — patch live data in-place + _patchPrinterBlock(existing.block, st); + } + } + + // Camera settings — per-printer toggles on Settings page + renderCameraSettings(printers); + + // Recent jobs — rendered into the Jobs page + const jobsWrap = $('jobsPageWrap'); + if (jobsWrap) { + const jobsFp = _jobsFingerprint(printers); + if (_renderedJobsCard?.fingerprint !== jobsFp) { + const newCard = renderRecentJobsCard(printers); + jobsWrap.innerHTML = ''; + jobsWrap.appendChild(newCard); + _renderedJobsCard = { el: newCard, fingerprint: jobsFp }; + } } } 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(); const st = j.result || j; spoolmanConfigured = !!st.spoolman_configured; render(st); - restoreUiState(); - if (rightCol && scrollTop != null) rightCol.scrollTop = scrollTop; } catch (e) { - badge($("printerBadge"), t('badge.printer_dash'), "warn"); - badge($("cfsBadge"), t('badge.cfs_off'), "warn"); + const pb = $("printerBadge"); + const cb = $("cfsBadge"); + if (pb) badge(pb, 'Printers: —', "warn"); + if (cb) badge(cb, 'CFS: —', "warn"); } } @@ -977,27 +2067,160 @@ function initRefreshControls() { applyRefreshTimer(); } -function initLangSwitcher() { - const btns = document.querySelectorAll('.langBtn'); - function updateActive() { - const cur = i18nLang(); - for (const b of btns) b.classList.toggle('active', b.dataset.lang === cur); +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);})();"; + + // Wire up both footer link and settings modal link + for (const id of ['fluiddBookmarklet', 'fluiddBookmarkletSettings']) { + const link = document.getElementById(id); + if (link) link.href = code; } - for (const b of btns) { - b.addEventListener('click', () => { - i18nSetLang(b.dataset.lang); - updateActive(); - tick(); // re-render dynamic content with new language - }); + + // Wire up both copy buttons (footer + settings modal) + function makeCopyHandler(btn) { + if (!btn) return; + 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); + } + }; } - updateActive(); + makeCopyHandler(document.getElementById('fluiddCopyBtn')); + makeCopyHandler(document.getElementById('fluiddCopyBtnSettings')); +} + +function initFluiddUserscript() { + function makeUserscriptHandler(btn) { + 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); + } + }; + } + + // Wire up both footer button and settings modal button + makeUserscriptHandler(document.getElementById('fluiddUserscriptBtn')); + makeUserscriptHandler(document.getElementById('fluiddUserscriptBtnSettings')); +} + +function _initSettingsHandlers() { + // Theme toggle + const themeToggle = document.getElementById('themeToggle'); + if (themeToggle) { + themeToggle.checked = document.documentElement.getAttribute('data-theme') === 'dark'; + themeToggle.onchange = () => { + const t = themeToggle.checked ? 'dark' : 'light'; + document.documentElement.setAttribute('data-theme', t); + localStorage.setItem('theme', t); + }; + } + + // Spoolman URL save — wired regardless of which container holds the form + const urlInput = $('settingsSpoolmanUrl'); + const urlSave = $('settingsSpoolmanUrlSave'); + const urlStatus = $('settingsSpoolmanUrlStatus'); + if (urlSave && urlInput) { + urlSave.onclick = async () => { + urlSave.disabled = true; + if (urlStatus) urlStatus.textContent = 'Saving…'; + try { + const res = await postJson('/api/ui/set_spoolman_url', { url: urlInput.value.trim() }); + const st = (res && res.result) ? res.result : res; + spoolmanConfigured = !!st.spoolman_configured; + render(st); + if (urlStatus) urlStatus.textContent = st.spoolman_url ? '✓ Saved' : '✓ Cleared'; + } catch (e) { + if (urlStatus) urlStatus.textContent = 'Error: ' + (e.message || String(e)); + } finally { + urlSave.disabled = false; + setTimeout(() => { if (urlStatus) urlStatus.textContent = ''; }, 3000); + } + }; + } +} + +function initNavDrawer() { + const drawer = $('navDrawer'); + if (!drawer) return; + const backdrop = $('navDrawerBackdrop'); + const closeBtn = $('navDrawerClose'); + + function openDrawer() { + _drawerOpen = true; + drawer.classList.add('navDrawer--open'); + } + function closeDrawer() { + _drawerOpen = false; + drawer.classList.remove('navDrawer--open'); + } + + const menuBtn = $('menuBtn'); + if (menuBtn) menuBtn.onclick = openDrawer; + if (closeBtn) closeBtn.onclick = (ev) => { ev.stopPropagation(); closeDrawer(); }; + if (backdrop) backdrop.onclick = closeDrawer; + + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && _drawerOpen) closeDrawer(); + }); + + // Nav item clicks — navigateTo closes the drawer automatically + for (const item of document.querySelectorAll('.navItem[data-page]')) { + item.addEventListener('click', () => navigateTo(item.dataset.page)); + } + + _initSettingsHandlers(); } function boot() { - i18nSetLang(i18nDetectLang()); - initLangSwitcher(); initSpoolModal(); + initHistoryRelinkModal(); + initNavDrawer(); + 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..eff3ff7 --- /dev/null +++ b/static/fluidd-panel.js @@ -0,0 +1,319 @@ +// 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; + 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 : {}; + + // 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('1', '2'); + + 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; + + const pod = document.createElement('div'); + pod.className = 'cfsp-slot' + (isActive ? ' active' : ''); + + // Color swatch + const dot = document.createElement('div'); + dot.className = 'cfsp-dot'; + const rawColor = (cfs.color || cfs.color_hex || local.color || local.color_hex || '').toString().toLowerCase(); + if (rawColor && cfs.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 (mat) { + const matEl = document.createElement('div'); + matEl.className = 'cfsp-mat'; + matEl.textContent = mat; + pod.appendChild(matEl); + } + + // Percent remaining + if (cfs.percent != null) { + const pctEl = document.createElement('div'); + pctEl.className = 'cfsp-pct'; + pctEl.textContent = cfs.percent + '%'; + pod.appendChild(pctEl); + } + + slotWrap.appendChild(pod); + } + row.appendChild(slotWrap); + slotsContainer.appendChild(row); + } + + 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(); + const result = j.result || j; + // Multi-printer format: {printers: [{id, state}, ...]} + // Single-printer legacy format: flat state object + const state = (result.printers && result.printers.length) + ? result.printers[0].state + : result; + renderState(state); + } 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/i18n.js b/static/i18n.js deleted file mode 100644 index 0aa69b6..0000000 --- a/static/i18n.js +++ /dev/null @@ -1,231 +0,0 @@ -/* i18n – lightweight German / English translations */ - -const I18N = { - de: { - // Page - 'page.title': 'Filament Anzeige (K2 Plus / CFS)', - 'header.title': 'Filament Anzeige', - - // Status tags - 'status.empty': 'leer', - 'status.active': 'aktiv', - 'status.ready': 'bereit', - - // Section titles - 'section.active': 'Aktiv', - 'section.history': 'Historie pro Slot', - 'section.history_last4': 'letzte 4', - 'section.moon_summary': 'Moonraker-History (gesamt)', - - // Refresh control - 'refresh.title': 'Update-Intervall', - 'refresh.toggle_title': 'Auto-Update an/aus', - - // Spool modal - 'modal.close': 'Schließen', - 'modal.weigh_label': 'Istgewicht (g)', - 'modal.weigh_ph': 'z.B. 206', - 'modal.btn_apply': 'Übernehmen', - 'modal.newroll_label': 'Neue Rolle (g)', - 'modal.newroll_ph': 'z.B. 1000', - 'modal.btn_rollchange': 'Rollwechsel', - 'modal.hint': 'Hinweis: Das speichert nur lokal in dieser App (kein POST an den Drucker). Rollwechsel versteckt alte Drucke in der Slot-Historie (bleibt intern gespeichert).', - - // Spool stats - 'spool.stats_full': 'Rest (berechnet): {remaining} · verbraucht seit Übernahme: {used} · Gesamt (Slot): {total}', - 'spool.stats_partial': 'Rest (aktuell): {remaining} · Tipp: "Istgewicht" eintragen und Übernehmen.', - 'spool.stats_none': 'Noch kein Referenzwert. Trage "Istgewicht" ein und klicke Übernehmen.', - - // Moonraker history - 'moon.empty': 'Keine Moonraker-History Daten', - 'moon.no_consumption': 'Kein Verbrauch in History gefunden', - - // History - 'history.no_name': '(ohne name)', - 'history.active_suffix': ' · aktiv', - 'history.no_data': 'Noch keine Daten', - - // Assignment - 'assign.title_existing': 'Zuordnung (lokal gespeichert)', - 'assign.title_new': 'Zu Slot zuordnen (lokal)', - 'assign.btn_edit': 'Ändern', - 'assign.select_default': '— Slot wählen —', - 'assign.total': 'gesamt', - 'assign.btn_update': 'Zuordnung aktualisieren', - 'assign.btn_assign': 'Zuordnen', - 'assign.alert_select': 'Bitte mindestens einen Slot wählen.', - 'assign.error_save': 'Konnte nicht speichern: ', - 'assign.current': 'Aktuell: ', - - // Badges - 'badge.printer_ok': 'Printer: verbunden', - 'badge.printer_off': 'Printer: getrennt', - 'badge.printer_dash': 'Printer: —', - 'badge.cfs_ok': 'CFS: erkannt · {ts}', - 'badge.cfs_off': 'CFS: —', - - // Footer - 'footer.tip': 'Tip: Wenn Farben/Material nicht angezeigt werden, prüfe in data/config.json die moonraker_url.', - - // Spoolman - 'spoolman.section': 'Spoolman', - 'spoolman.not_linked': 'nicht verknüpft', - 'spoolman.linked': 'verknüpft', - 'spoolman.linked_info': 'Spool #{id} · {vendor} {name} · {remaining}', - 'spoolman.btn_link': 'Verknüpfen', - 'spoolman.btn_unlink': 'Trennen', - 'spoolman.btn_refresh': 'Aktualisieren', - 'spoolman.select_ph': '— Spool wählen —', - 'spoolman.loading': 'Lade Spools …', - 'spoolman.error': 'Spoolman-Fehler: {msg}', - 'spoolman.no_spools': 'Keine Spools gefunden', - 'spoolman.option_label': '#{id} {vendor} {name} · {material} · {remaining}', - - // Language - 'lang.de': 'DE', - 'lang.en': 'EN', - }, - - en: { - // Page - 'page.title': 'Filament Display (K2 Plus / CFS)', - 'header.title': 'Filament Display', - - // Status tags - 'status.empty': 'empty', - 'status.active': 'active', - 'status.ready': 'ready', - - // Section titles - 'section.active': 'Active', - 'section.history': 'History per Slot', - 'section.history_last4': 'last 4', - 'section.moon_summary': 'Moonraker History (total)', - - // Refresh control - 'refresh.title': 'Refresh interval', - 'refresh.toggle_title': 'Auto-refresh on/off', - - // Spool modal - 'modal.close': 'Close', - 'modal.weigh_label': 'Current weight (g)', - 'modal.weigh_ph': 'e.g. 206', - 'modal.btn_apply': 'Apply', - 'modal.newroll_label': 'New roll (g)', - 'modal.newroll_ph': 'e.g. 1000', - 'modal.btn_rollchange': 'Roll change', - 'modal.hint': 'Note: This saves locally in this app only (no POST to printer). Roll change hides old prints in slot history (kept internally).', - - // Spool stats - 'spool.stats_full': 'Remaining (calc): {remaining} · used since reference: {used} · Total (slot): {total}', - 'spool.stats_partial': 'Remaining (current): {remaining} · Tip: enter "Current weight" and click Apply.', - 'spool.stats_none': 'No reference yet. Enter "Current weight" and click Apply.', - - // Moonraker history - 'moon.empty': 'No Moonraker history data', - 'moon.no_consumption': 'No consumption found in history', - - // History - 'history.no_name': '(unnamed)', - 'history.active_suffix': ' · active', - 'history.no_data': 'No data yet', - - // Assignment - 'assign.title_existing': 'Assignment (saved locally)', - 'assign.title_new': 'Assign to slot (local)', - 'assign.btn_edit': 'Edit', - 'assign.select_default': '— Pick slot —', - 'assign.total': 'total', - 'assign.btn_update': 'Update assignment', - 'assign.btn_assign': 'Assign', - 'assign.alert_select': 'Please select at least one slot.', - 'assign.error_save': 'Could not save: ', - 'assign.current': 'Current: ', - - // Badges - 'badge.printer_ok': 'Printer: connected', - 'badge.printer_off': 'Printer: disconnected', - 'badge.printer_dash': 'Printer: —', - 'badge.cfs_ok': 'CFS: detected · {ts}', - 'badge.cfs_off': 'CFS: —', - - // Footer - 'footer.tip': 'Tip: If colors/material are not shown, check moonraker_url in data/config.json.', - - // Spoolman - 'spoolman.section': 'Spoolman', - 'spoolman.not_linked': 'not linked', - 'spoolman.linked': 'linked', - 'spoolman.linked_info': 'Spool #{id} · {vendor} {name} · {remaining}', - 'spoolman.btn_link': 'Link', - 'spoolman.btn_unlink': 'Unlink', - 'spoolman.btn_refresh': 'Refresh', - 'spoolman.select_ph': '— Pick spool —', - 'spoolman.loading': 'Loading spools…', - 'spoolman.error': 'Spoolman error: {msg}', - 'spoolman.no_spools': 'No spools found', - 'spoolman.option_label': '#{id} {vendor} {name} · {material} · {remaining}', - - // Language - 'lang.de': 'DE', - 'lang.en': 'EN', - } -}; - -let _i18nLang = 'en'; - -/** - * Translate a key, optionally replacing {placeholder} tokens. - * Falls back to English, then returns the key itself. - */ -function t(key, params) { - let s = (I18N[_i18nLang] && I18N[_i18nLang][key]) || (I18N.en && I18N.en[key]) || key; - if (params) { - for (const [k, v] of Object.entries(params)) { - s = s.replace(new RegExp('\\{' + k + '\\}', 'g'), v); - } - } - return s; -} - -/** Detect preferred language: localStorage → navigator → fallback 'en' */ -function i18nDetectLang() { - const stored = localStorage.getItem('lang'); - if (stored === 'de' || stored === 'en') return stored; - const nav = (navigator.languages || [navigator.language || '']); - for (const l of nav) { - if (typeof l === 'string' && l.toLowerCase().startsWith('de')) return 'de'; - } - return 'en'; -} - -/** Set the active language, persist, and re-translate the DOM. */ -function i18nSetLang(lang) { - _i18nLang = (lang === 'de') ? 'de' : 'en'; - localStorage.setItem('lang', _i18nLang); - document.documentElement.lang = _i18nLang; - document.title = t('page.title'); - i18nTranslateDOM(); -} - -/** Translate static elements that carry data-i18n* attributes. */ -function i18nTranslateDOM() { - for (const el of document.querySelectorAll('[data-i18n]')) { - el.textContent = t(el.dataset.i18n); - } - for (const el of document.querySelectorAll('[data-i18n-html]')) { - el.innerHTML = t(el.dataset.i18nHtml); - } - for (const el of document.querySelectorAll('[data-i18n-placeholder]')) { - el.placeholder = t(el.dataset.i18nPlaceholder); - } - for (const el of document.querySelectorAll('[data-i18n-title]')) { - el.title = t(el.dataset.i18nTitle); - } -} - -/** Return the current language code ('de' | 'en'). */ -function i18nLang() { return _i18nLang; } - -// Auto-detect on load -_i18nLang = i18nDetectLang(); diff --git a/static/index.html b/static/index.html index ce7b6fa..14d5d9c 100644 --- a/static/index.html +++ b/static/index.html @@ -3,83 +3,200 @@ - Filament Display (K2 Plus / CFS) + CFSync + + + + + + +
+
- +
+ CFSync +
-
Filament Display
-
© bei jkef 2026
+
CFSync
+
Connecting…
-
-
- - +
+
+ + + +
+ +
+
Printers: —
+
CFS: —
-
Printer: —
-
CFS: —
-
-
-
- -
-
-
Active
-
-
-
- -
+ + +
+
+
+
+ + + - +
+ + +
+
Cameras
+
+
+ + +
+
Appearance
+
+
Theme
+
+ +
+
+
+ + +
+
Fluidd integration
+
+
Bookmarklet
+
+
+ Drag to bookmarks bar + +
+
Click in Fluidd to show live CFS slot status.
+
+
+
+
Tampermonkey
+
+ +
Auto-loads the panel on every Fluidd page visit.
+
+
+
+ +
-
- Tip: If colors/material are not shown, check moonraker_url in data/config.json. -
- - + - + + + +
+
+ + +