diff --git a/.github/ISSUE_TEMPLATE/filament-mismatch.md b/.github/ISSUE_TEMPLATE/filament-mismatch.md index f7aa91d7..d6e68ff7 100644 --- a/.github/ISSUE_TEMPLATE/filament-mismatch.md +++ b/.github/ISSUE_TEMPLATE/filament-mismatch.md @@ -16,7 +16,7 @@ A clear, short description of the mismatch (what you expected vs. what you saw). - `DISABLE_MISMATCH_WARNING` set?: ☐ Yes ☐ No ## Data to attach -- `data/filament_mismatch.json` (attach file or paste content) +- `logs/filament_mismatch.json` (attach file or paste content; includes the color difference when relevant) - screenshot of the mismatch message in the UI ## Additional context diff --git a/README.md b/README.md index 64bac295..5a597dc7 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ SpoolMan can print QR-code stickers for every spool; follow the SpoolMan label g - set `AUTO_SPEND` to `True` to enable legacy slicer-estimate tracking (no live layer tracking). - set `TRACK_LAYER_USAGE` to `True` to switch to per-layer tracking/consumption **while `AUTO_SPEND` is also `True`**. If `AUTO_SPEND` is `False`, all filament tracking remains disabled regardless of `TRACK_LAYER_USAGE`. - set `AUTO_SPEND` to `True` if you want automatic filament usage tracking (see the AUTO SPEND notes below). - - set `DISABLE_MISMATCH_WARNING` to `True` to hide mismatch warnings in the UI (mismatches are still detected and logged to `data/filament_mismatch.json`). + - set `DISABLE_MISMATCH_WARNING` to `True` to hide mismatch warnings in the UI (mismatches are still detected and logged to `logs/filament_mismatch.json`, including the detected color difference when applicable). - set `CLEAR_ASSIGNMENT_WHEN_EMPTY` to `True` if you want OpenSpoolMan to clear any SpoolMan assignment and reset the AMS tray whenever the printer reports no spool in that slot. - set `COLOR_DISTANCE_TOLERANCE` to an integer (default `40`) if you want to make the perceptual ΔE threshold for tray/spool color mismatch warnings stricter or more lenient; when either side (AMS tray or SpoolMan spool) lacks a color the warning is skipped and the UI shows "Color not set". - By default, the app reads `data/3d_printer_logs.db` for print history; override it through `OPENSPOOLMAN_PRINT_HISTORY_DB` or via the screenshot helper (which targets `data/demo.db` by default). @@ -204,7 +204,7 @@ SpoolMan can print QR-code stickers for every spool; follow the SpoolMan label g - `material` = base (e.g., `PLA`) and `type` = the add-on (e.g., `Wood`). Both must correspond to what the AMS reports for that tray. - You can wrap optional notes in parentheses inside `material` (e.g., `PLA CF (recycled)`); anything in parentheses is ignored during matching. -- If matching still fails, please file a report using `.github/ISSUE_TEMPLATE/filament-mismatch.md` or temporarily hide the UI warning via `DISABLE_MISMATCH_WARNING=true` (mismatches are still logged to `data/filament_mismatch.json`). +- If matching still fails, please file a report using `.github/ISSUE_TEMPLATE/filament-mismatch.md` or temporarily hide the UI warning via `DISABLE_MISMATCH_WARNING=true` (mismatches are still logged to `logs/filament_mismatch.json`, and color mismatches also capture the computed color distance). With NFC Tags: - For non-Bambu filament, select it in SpoolMan, click 'Write,' and tap an NFC tag near your phone (allow NFC). diff --git a/agents.md b/agents.md index 94573608..bafbdcd2 100644 --- a/agents.md +++ b/agents.md @@ -104,7 +104,7 @@ Use `docker compose port openspoolman 8001` to see mapped host port if needed. ### Data sources - Print history DB default: `data/3d_printer_logs.db` - Override via: `OPENSPOOLMAN_PRINT_HISTORY_DB` -- Mismatch log output: `data/filament_mismatch.json` +- Mismatch log output: `logs/filament_mismatch.json` (now includes the detected color distance when a color mismatch occurs) ### Important operational note If you change `OPENSPOOLMAN_BASE_URL`, NFC tags must be reconfigured. @@ -216,7 +216,7 @@ When debugging: - `PRINTER_IP` reachable from the OpenSpoolMan host/container - `PRINTER_ACCESS_CODE` correct - Inspect mismatch log: - - `data/filament_mismatch.json` + - `logs/filament_mismatch.json` - Confirm print history DB path: - `data/3d_printer_logs.db` or `OPENSPOOLMAN_PRINT_HISTORY_DB` diff --git a/api_routes.py b/api_routes.py new file mode 100644 index 00000000..280724ca --- /dev/null +++ b/api_routes.py @@ -0,0 +1,324 @@ +import json +import os +import traceback +from typing import Any, Dict, List, Optional, Tuple + +from flask import Blueprint, jsonify, request + +import mqtt_bambulab +import spoolman_client +import spoolman_service +import test_data +from config import EXTERNAL_SPOOL_AMS_ID, EXTERNAL_SPOOL_ID, PRINTER_ID, PRINTER_NAME + +API_VERSION = "v1" +api_bp = Blueprint("api", __name__, url_prefix=f"/api/{API_VERSION}") + +READ_ONLY_MODE = (not test_data.test_data_active()) and os.getenv("OPENSPOOLMAN_LIVE_READONLY") == "1" +ACTIVE_PRINTER_ID = (PRINTER_ID or "").upper() or "PRINTER_1" + + +def json_success(data: Any, status: int = 200): + return jsonify({"success": True, "data": data}), status + + +def json_error(code: str, message: str, status: int = 400): + return jsonify({"success": False, "error": {"code": code, "message": message}}), status + + +def _printer_matches(printer_id: str) -> bool: + return str(printer_id or "").upper() == ACTIVE_PRINTER_ID + + +def _clean_json_value(value: Any) -> Any: + if isinstance(value, str): + try: + return json.loads(value) + except Exception: + return value + return value + + +def _serialize_spool(spool: Dict[str, Any]) -> Dict[str, Any]: + filament = spool.get("filament", {}) or {} + extra = spool.get("extra", {}) or {} + + tag = _clean_json_value(extra.get("tag")) + + assigned_ams_id = None + assigned_tray_index = None + active_tray = extra.get("active_tray") + if active_tray: + try: + tray_uid = json.loads(active_tray) + parts = tray_uid.split("_") + if len(parts) >= 2: + assigned_tray_index = int(parts[-1]) + assigned_ams_id = int(parts[-2]) + except Exception: + assigned_ams_id = None + assigned_tray_index = None + + return { + "id": str(spool.get("id")), + "name": filament.get("name") or spool.get("name") or f"Spool {spool.get('id')}", + "material": filament.get("material") or "", + "vendor": (filament.get("vendor") or {}).get("name"), + "color": filament.get("multi_color_hexes") or filament.get("color_hex") or "", + "diameter_mm": filament.get("diameter"), + "weight_g": spool.get("initial_weight") or filament.get("weight"), + "remaining_g": spool.get("remaining_weight"), + "tag": tag, + "location": spool.get("location"), + "ams_id": assigned_ams_id, + "tray_index": assigned_tray_index, + } + + +def _find_spool_for_tray(spools: List[Dict[str, Any]], ams_id: int, tray_id: int) -> Optional[Dict[str, Any]]: + tray_uid = spoolman_service.trayUid(ams_id, tray_id) + for spool in spools: + active = _clean_json_value((spool.get("extra") or {}).get("active_tray")) + if active and active == tray_uid: + return spool + return None + + +def _serialize_tray(tray: Dict[str, Any], spools: List[Dict[str, Any]], ams_id: int) -> Dict[str, Any]: + tray_id = int(tray.get("id") or 0) + matched_spool = _find_spool_for_tray(spools, ams_id, tray_id) + + filament = matched_spool.get("filament", {}) if matched_spool else {} + spool_name = filament.get("name") if matched_spool else None + spool_id = matched_spool.get("id") if matched_spool else None + vendor = (filament.get("vendor") or {}).get("name") + material = filament.get("material") or tray.get("tray_type") or "" + + tray_color_raw = tray.get("tray_color") or "" + tray_color = spoolman_service.normalize_color_hex(tray_color_raw) + tray_color_value = f"#{tray_color}" if tray_color else "" + + spool_color_value = "" + color_mismatch = False + color_mismatch_message = "" + has_multi_color = False + raw_multi_color = filament.get("multi_color_hexes") + if raw_multi_color: + has_multi_color = True + first_color = None + if isinstance(raw_multi_color, list): + first_color = raw_multi_color[0] if raw_multi_color else None + else: + first_color = str(raw_multi_color).split(",")[0] + normalized = spoolman_service.normalize_color_hex(first_color or "") + if normalized: + spool_color_value = f"#{normalized}" + else: + normalized = spoolman_service.normalize_color_hex(filament.get("color_hex") or "") + if normalized: + spool_color_value = f"#{normalized}" + + if not has_multi_color and tray_color_value and spool_color_value: + distance = spoolman_service.color_distance(tray_color_value, spool_color_value) + if distance is not None and distance > spoolman_service.COLOR_DISTANCE_TOLERANCE: + color_mismatch = True + color_mismatch_message = "Colors are not similar." + + color_value = spool_color_value or tray_color_value + active = bool(tray.get("state") == 3 or matched_spool) + is_loaded = bool(tray.get("remain")) or bool(matched_spool) + remaining_g = None + if matched_spool: + remaining_g = matched_spool.get("remaining_weight") + if remaining_g is None: + remaining_g = matched_spool.get("remain") + else: + remaining_g = tray.get("remain") + + return { + "index": tray_id, + "ams_id": ams_id, + "spool_id": spool_id, + "spool_name": spool_name, + "material": material, + "color": color_value, + "tray_color": tray_color_value, + "spool_color": spool_color_value, + "color_mismatch": color_mismatch, + "color_mismatch_message": color_mismatch_message, + "spool_vendor": vendor, + "remaining_g": remaining_g, + "active": active, + "is_loaded": is_loaded, + } + + +def _load_printer_summary() -> Dict[str, Any]: + model = mqtt_bambulab.getPrinterModel() + name = PRINTER_NAME or model.get("devicename") or "Printer" + + return { + "id": ACTIVE_PRINTER_ID, + "name": name, + "online": mqtt_bambulab.isMqttClientConnected(), + "last_seen": None, + } + + +def _load_trays() -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: + config = mqtt_bambulab.getLastAMSConfig() or {} + spools = mqtt_bambulab.fetchSpools() + + trays: List[Dict[str, Any]] = [] + + vt_tray = config.get("vt_tray") + if vt_tray: + trays.append(_serialize_tray(vt_tray, spools, EXTERNAL_SPOOL_AMS_ID)) + + for ams in config.get("ams", []): + ams_id = int(ams.get("id", 0)) + for tray in ams.get("tray", []): + trays.append(_serialize_tray(tray, spools, ams_id)) + + return trays, config + + +def _resolve_tray_context(tray_index: int) -> Tuple[Optional[int], Optional[int]]: + config = mqtt_bambulab.getLastAMSConfig() or {} + + vt_tray = config.get("vt_tray") + if vt_tray and int(vt_tray.get("id", -1)) == tray_index: + return EXTERNAL_SPOOL_AMS_ID, tray_index + + for ams in config.get("ams", []): + ams_id = int(ams.get("id", -1)) + for tray in ams.get("tray", []): + if int(tray.get("id", -1)) == tray_index: + return ams_id, tray_index + + return None, None + + +@api_bp.route("/printers", methods=["GET"]) +def api_list_printers(): + try: + printer = _load_printer_summary() + return json_success([printer]) + except Exception as exc: + traceback.print_exc() + return json_error("PRINTER_FETCH_FAILED", f"Failed to load printer info: {exc}", 500) + + +@api_bp.route("/printers//ams", methods=["GET"]) +def api_get_ams(printer_id: str): + if not _printer_matches(printer_id): + return json_error("PRINTER_NOT_FOUND", f"Printer '{printer_id}' not found", 404) + + try: + trays, _ = _load_trays() + payload = {"printer_id": ACTIVE_PRINTER_ID, "ams_slots": trays} + return json_success(payload) + except Exception as exc: + traceback.print_exc() + return json_error("AMS_FETCH_FAILED", f"Failed to fetch AMS data: {exc}", 500) + + +@api_bp.route("/spools", methods=["GET"]) +def api_get_spools(): + try: + spools = spoolman_service.fetchSpools() + return json_success([_serialize_spool(spool) for spool in spools]) + except Exception as exc: + traceback.print_exc() + return json_error("SPOOL_FETCH_FAILED", f"Failed to fetch spools: {exc}", 500) + + +@api_bp.route("/printers//ams//assign", methods=["POST"]) +def api_assign_tray(printer_id: str, tray_index: int): + if not _printer_matches(printer_id): + return json_error("PRINTER_NOT_FOUND", f"Printer '{printer_id}' not found", 404) + + if READ_ONLY_MODE: + return json_error("READ_ONLY_MODE", "Live read-only mode: assigning spools to trays is disabled.", 403) + + if not mqtt_bambulab.isMqttClientConnected(): + return json_error("PRINTER_OFFLINE", "MQTT is disconnected. Is the printer online?", 503) + + body = request.get_json(silent=True) or {} + spool_id = body.get("spool_id") + + if not spool_id: + return json_error("INVALID_REQUEST", "Field 'spool_id' is required.", 400) + + ams_id = body.get("ams_id") + if ams_id is None: + ams_id, resolved_tray = _resolve_tray_context(tray_index) + if resolved_tray is None: + return json_error("TRAY_NOT_FOUND", f"Tray '{tray_index}' not found", 404) + else: + try: + ams_id = int(ams_id) + except (TypeError, ValueError): + return json_error("INVALID_REQUEST", "ams_id must be an integer when provided.", 400) + resolved_tray = tray_index + + try: + spool_data = spoolman_client.getSpoolById(spool_id) + except Exception as exc: + traceback.print_exc() + return json_error("SPOOL_FETCH_FAILED", f"Failed to fetch spool '{spool_id}': {exc}", 502) + + if not spool_data or spool_data.get("id") is None: + return json_error("SPOOL_NOT_FOUND", f"Spool '{spool_id}' not found", 404) + + try: + mqtt_bambulab.setActiveTray(spool_id, spool_data.get("extra"), ams_id, resolved_tray) + + # Reuse the existing assignment logic from app.setActiveSpool to keep behavior aligned with /fill. + from app import setActiveSpool # Local import to avoid circular dependency at module load time + setActiveSpool(ams_id, resolved_tray, spool_data) + except Exception as exc: + traceback.print_exc() + return json_error("ASSIGN_FAILED", f"Failed to assign spool '{spool_id}' to tray '{tray_index}': {exc}", 500) + + return json_success({"printer_id": ACTIVE_PRINTER_ID, "tray_index": tray_index, "ams_id": ams_id, "spool_id": spool_id}) + + +@api_bp.route("/printers//ams//unassign", methods=["POST"]) +def api_unassign_tray(printer_id: str, tray_index: int): + if not _printer_matches(printer_id): + return json_error("PRINTER_NOT_FOUND", f"Printer '{printer_id}' not found", 404) + + if READ_ONLY_MODE: + return json_error("READ_ONLY_MODE", "Live read-only mode: assigning spools to trays is disabled.", 403) + + body = request.get_json(silent=True) or {} + spool_id = body.get("spool_id") + + try: + spool: Optional[Dict[str, Any]] = None + if spool_id: + spool = spoolman_client.getSpoolById(spool_id) + else: + spools = spoolman_service.fetchSpools() + ams_id, _ = _resolve_tray_context(tray_index) + if ams_id is None: + return json_error("TRAY_NOT_FOUND", f"Tray '{tray_index}' not found", 404) + spool = _find_spool_for_tray(spools, ams_id, tray_index) + + if not spool or spool.get("id") is None: + return json_error("SPOOL_NOT_FOUND", "No spool assigned to this tray", 404) + + extras = spool.get("extra") or {} + spoolman_client.patchExtraTags(spool["id"], extras, {"active_tray": ""}) + return json_success( + { + "printer_id": ACTIVE_PRINTER_ID, + "tray_index": tray_index, + "spool_id": spool["id"], + "unassigned": True, + } + ) + except Exception as exc: + traceback.print_exc() + return json_error("UNASSIGN_FAILED", f"Failed to unassign tray: {exc}", 500) diff --git a/app.py b/app.py index 5e68fb40..9161b297 100644 --- a/app.py +++ b/app.py @@ -716,3 +716,7 @@ def print_select_spool(): except Exception as e: traceback.print_exc() return render_template('error.html', exception=str(e)) + +# Register REST API blueprint +from api_routes import api_bp +app.register_blueprint(api_bp) diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 00000000..62612f3c --- /dev/null +++ b/docs/api.md @@ -0,0 +1,109 @@ +# OpenSpoolman JSON API + +Alle Endpoints liegen unter `/api/v1` und geben folgendes Schema zurück: + +```json +// Erfolg +{ "success": true, "data": { ... } } + +// Fehler +{ "success": false, "error": { "code": "CODE", "message": "Details" } } +``` + +## Endpoints +### GET `/api/v1/printers` +Liefert die bekannte Druckerinstanz. +```json +{ + "success": true, + "data": [ + { "id": "PRINTER_ID", "name": "Friendly Name", "online": true, "last_seen": null } + ] +} +``` + +### GET `/api/v1/printers/{printer_id}/ams` +AMS-/Tray-Status für den Drucker. +```json +{ + "success": true, + "data": { + "printer_id": "PRINTER_ID", + "ams_slots": [ + { + "index": 1, + "ams_id": 0, + "spool_id": 123, + "spool_name": "PLA White", + "material": "PLA", + "color": "FFFFFF", + "active": true, + "is_loaded": true + } + ] + } +} +``` + +### GET `/api/v1/spools` +Liste aller Spulen aus Spoolman. +```json +{ + "success": true, + "data": [ + { + "id": "123", + "name": "PLA White", + "material": "PLA", + "color": "FFFFFF", + "diameter_mm": 1.75, + "weight_g": 1000, + "remaining_g": 750, + "tag": "RFID1234", + "location": "Rack 1 / Bin 3" + } + ] +} +``` + +### POST `/api/v1/printers/{printer_id}/ams/{tray_index}/assign` +Spule einem Tray zuweisen. Optional `ams_id` im Body mitgeben, wenn mehrere AMS existieren. + +Request-Body: +```json +{ "spool_id": "123", "ams_id": 0 } +``` + +Antwort: +```json +{ + "success": true, + "data": { "printer_id": "PRINTER_ID", "ams_id": 0, "tray_index": 1, "spool_id": "123" } +} +``` + +Fehlerbeispiele: +- 403 `READ_ONLY_MODE` wenn `OPENSPOOLMAN_LIVE_READONLY=1` +- 404 `PRINTER_NOT_FOUND` oder `TRAY_NOT_FOUND` +- 404 `SPOOL_NOT_FOUND` +- 503 `PRINTER_OFFLINE` wenn MQTT-Verbindung fehlt + +### POST `/api/v1/printers/{printer_id}/ams/{tray_index}/unassign` +Zuweisung einer Spule von einem Tray entfernen. Optional `spool_id` angeben, sonst wird die aktive Spule anhand `active_tray` gesucht. + +Request-Body: +```json +{ "spool_id": "123" } +``` + +Antwort: +```json +{ + "success": true, + "data": { "printer_id": "PRINTER_ID", "tray_index": 1, "spool_id": "123", "unassigned": true } +} +``` + +Fehlerbeispiele: +- 404 `SPOOL_NOT_FOUND` wenn keine Spule gefunden wurde +- 403 `READ_ONLY_MODE` wenn `OPENSPOOLMAN_LIVE_READONLY=1` diff --git a/mqtt_bambulab.py b/mqtt_bambulab.py index e3872ef9..2d704886 100644 --- a/mqtt_bambulab.py +++ b/mqtt_bambulab.py @@ -158,11 +158,44 @@ def update_dict(original: dict, updates: dict) -> dict: return original -def _parse_grams(value): - try: - return float(value) - except (TypeError, ValueError): - return None +def _parse_grams(value): + try: + return float(value) + except (TypeError, ValueError): + return None + +def _mask_serial(serial: str | None, keep_chars: int = 3) -> str: + if not serial: + return "" + visible = serial[:keep_chars] + if len(serial) <= keep_chars: + return visible + return f"{visible}..." + +def _mask_sn_values(value): + if isinstance(value, dict): + for key, item in value.items(): + if key.lower() == "sn" and isinstance(item, str): + value[key] = _mask_serial(item) + else: + _mask_sn_values(item) + elif isinstance(value, list): + for elem in value: + _mask_sn_values(elem) + +def _mask_mqtt_payload(payload: str) -> str: + try: + data = json.loads(payload) + _mask_sn_values(data) + masked = json.dumps(data, separators=(",", ":")) + except ValueError: + masked = payload + + masked_serial = _mask_serial(PRINTER_ID) + if masked_serial: + masked = masked.replace(PRINTER_ID, masked_serial) + + return masked def map_filament(tray_tar): global PENDING_PRINT_METADATA @@ -416,8 +449,8 @@ def on_message(client, userdata, msg): "models_by_id": models_by_id, } - if "print" in data: - append_to_rotating_file("/home/app/logs/mqtt.log", msg.payload.decode()) + if "print" in data: + append_to_rotating_file("/home/app/logs/mqtt.log", _mask_mqtt_payload(msg.payload.decode())) #print(data) diff --git a/spoolman_service.py b/spoolman_service.py index d4af8efa..181cce62 100644 --- a/spoolman_service.py +++ b/spoolman_service.py @@ -73,7 +73,11 @@ def getAMSFromTray(n): def normalize_color_hex(color_hex): - if not color_hex or isinstance(color_hex, list): + if not color_hex: + return "" + if isinstance(color_hex, list): + return "" + if not isinstance(color_hex, str): return "" color = color_hex.strip().upper() @@ -140,18 +144,35 @@ def color_distance(color1, color2): return math.sqrt(sum((a - b) ** 2 for a, b in zip(lab1, lab2))) -def _log_filament_mismatch(tray_data: dict, spool: dict) -> None: +def _log_filament_mismatch(tray_data: dict, spool: dict, color_distance=None, reason="material_mismatch") -> None: try: - data_path = Path("data/filament_mismatch.json") + data_path = Path("logs/filament_mismatch.json") data_path.parent.mkdir(parents=True, exist_ok=True) timestamp = datetime.utcnow().isoformat() + "Z" - - with data_path.open("w", encoding="utf-8") as f: - json.dump({ - "timestamp": timestamp, - "tray": tray_data, - "spool": spool, - }, f) + + log_entry = { + "timestamp": timestamp, + "reason": reason, + "tray": tray_data, + "spool": spool, + } + + if color_distance is not None: + log_entry["color_distance"] = color_distance + + entries = [] + if data_path.exists(): + try: + existing = json.loads(data_path.read_text(encoding="utf-8")) + if isinstance(existing, list): + entries = existing + elif isinstance(existing, dict): + entries = [existing] + except json.JSONDecodeError: + entries = [] + + entries.append(log_entry) + data_path.write_text(json.dumps(entries, indent=2), encoding="utf-8") except Exception: pass @@ -323,6 +344,7 @@ def _clean_basic(val: str) -> str: if color_difference is not None and color_difference > COLOR_DISTANCE_TOLERANCE: tray_data["color_mismatch"] = True tray_data["color_mismatch_message"] = "Colors are not similar." + _log_filament_mismatch(tray_data, spool, color_distance=color_difference, reason="color_mismatch") break @@ -417,8 +439,8 @@ def setActiveTray(spool_id, spool_extra, ams_id, tray_id): }) # Remove active tray from inactive spools - for old_spool in fetchSpools(cached=True): - if spool_id != old_spool["id"] and old_spool.get("extra") and old_spool["extra"].get("active_tray") and json.loads(old_spool["extra"]["active_tray"]) == trayUid(ams_id, tray_id): + for old_spool in fetchSpools(cached=False): + if int(spool_id) != old_spool["id"] and old_spool.get("extra") and old_spool["extra"].get("active_tray") and json.loads(old_spool["extra"]["active_tray"]) == trayUid(ams_id, tray_id): spoolman_client.patchExtraTags(old_spool["id"], old_spool["extra"], {"active_tray": json.dumps("")}) else: log("Skipping set active tray") diff --git a/templates/fragments/list_prints.html b/templates/fragments/list_prints.html index e3734a13..b00cc49b 100644 --- a/templates/fragments/list_prints.html +++ b/templates/fragments/list_prints.html @@ -110,8 +110,18 @@ {% endif %}
-
- {% for filament in print["filament_usage"] %} +
+ {% for filament in print["filament_usage"] %} + {% set length_title = None %} + {% set length_value = filament['length_used'] %} + {% if length_value is not none and (length_value|float) > 0 %} + {% set length_title = "Length: " + '{:,.0f}'.format(length_value|float) + "mm" %} + {% endif %} + {% set expected_title = None %} + {% set expected_length = filament['estimated_length'] %} + {% if expected_length is not none and (expected_length|float) > 0 %} + {% set expected_title = "Slicer metadata length: " + '{:,.0f}'.format(expected_length|float) + "mm" %} + {% endif %}
{% if filament['spool'] %} @@ -148,20 +158,29 @@
#{{ filament['spool'].id }} – {{ filament['spool'].filament.vendor.name }} – {{ filament['spool'].filament.material }}
- - {{ filament['spool'].filament.name }} - - {% if filament['grams_used'] is not none %} + + {{ filament['spool'].filament.name }} + + {% if filament['grams_used'] is not none %} + {{ '%.2f' | format(filament['grams_used']) }}g printed - {% endif %} - {% if filament['estimated_grams'] is not none %} + + {% endif %} + {% if filament['estimated_grams'] is not none %} + / {{ '%.2f' | format(filament['estimated_grams']) }}g expected + + {% if expected_length is not none and (expected_length|float) > 0 %} + + (~{{ '{:,.0f}'.format(expected_length|float) }}mm) + {% endif %} - - - {{ '%.2f' | format(filament['cost']|float) }} {{currencysymbol}} - - + {% endif %} + + + {{ '%.2f' | format(filament['cost']|float) }} {{currencysymbol}} + +
@@ -184,12 +203,21 @@
#{{ filament['spool'].id }} – {{ filament['spool'].filament.vendor.name }}
No spool assigned - {{ filament['filament_type'] }}
- + + {{ '%.2f' | format(filament['grams_used'] or 0) }}g printed - {% if filament['estimated_grams'] is not none %} - / {{ '%.2f' | format(filament['estimated_grams']) }}g expected - {% endif %} - + + {% if filament['estimated_grams'] is not none %} + + / {{ '%.2f' | format(filament['estimated_grams']) }}g expected + + {% if expected_length is not none and (expected_length|float) > 0 %} + + (~{{ '%.0f' | format(expected_length|float) }}mm) + + {% endif %} + {% endif %} +
diff --git a/tests/test_filament_mismatch.py b/tests/test_filament_mismatch.py index e52a3ced..46d7c235 100644 --- a/tests/test_filament_mismatch.py +++ b/tests/test_filament_mismatch.py @@ -33,11 +33,15 @@ def _make_spool(material, extra_type, ams_id=0, tray_id="tray-1", spool_id=1, sp } -def _run_case(tray, spool, ams_id=0, tray_id="tray-1"): +def _run_case(tray, spool, ams_id=0, tray_id="tray-1", log_stub=None): spool_list = [spool] # avoid file writes during tests - svc._log_filament_mismatch = lambda *args, **kwargs: None - svc.augmentTrayDataWithSpoolMan(spool_list, tray, ams_id, tray_id) + original_log_fn = svc._log_filament_mismatch + svc._log_filament_mismatch = log_stub or (lambda *args, **kwargs: None) + try: + svc.augmentTrayDataWithSpoolMan(spool_list, tray, ams_id, tray_id) + finally: + svc._log_filament_mismatch = original_log_fn return tray @@ -113,6 +117,26 @@ def test_mismatch_warning_can_be_disabled(monkeypatch): assert result["mismatch"] is False # hidden in UI +def test_color_mismatch_logs_distance(): + tray = _make_tray("PLA", "") + tray["tray_color"] = "FFFFFF" + spool = _make_spool("PLA", "") + spool["filament"]["color_hex"] = "000000" + + captured = {} + + def _capture_log(tray_logged, spool_logged, color_distance=None, reason="material_mismatch"): + captured["color_distance"] = color_distance + captured["reason"] = reason + + result = _run_case(tray, spool, log_stub=_capture_log) + + assert result["color_mismatch"] is True + assert captured["reason"] == "color_mismatch" + assert captured["color_distance"] is not None + assert captured["color_distance"] > svc.COLOR_DISTANCE_TOLERANCE + + BAMBULAB_BASE_MAPPINGS = [ # tray_type, tray_sub_brands, spool_material, spool_type, expected_match ("ABS", "", "ABS", "", True),