From ff77764784df9eaf88ab7526bff649dfc9271543 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 13 Dec 2025 21:30:44 +0100 Subject: [PATCH 01/12] update --- api_routes.py | 322 +++++++++++++++++++++++++++++++++++++++ app.py | 18 ++- docs/api.md | 110 +++++++++++++ spoolman_service.py | 6 +- tests/test_api_routes.py | 61 ++++++++ 5 files changed, 509 insertions(+), 8 deletions(-) create mode 100644 api_routes.py create mode 100644 docs/api.md create mode 100644 tests/test_api_routes.py diff --git a/api_routes.py b/api_routes.py new file mode 100644 index 00000000..974bbc6a --- /dev/null +++ b/api_routes.py @@ -0,0 +1,322 @@ +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_bp = Blueprint("api", __name__, url_prefix="/api") + +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 "", + "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 a7b846fb..e036845b 100644 --- a/app.py +++ b/app.py @@ -445,11 +445,11 @@ def print_history(): ) @app.route("/print_select_spool") -def print_select_spool(): - - try: - ams_slot = request.args.get("ams_slot") - print_id = request.args.get("print_id") +def print_select_spool(): + + try: + ams_slot = request.args.get("ams_slot") + print_id = request.args.get("print_id") old_spool_id = request.args.get("old_spool_id") change_spool = request.args.get("change_spool", "false").lower() == "true" @@ -485,6 +485,10 @@ def print_select_spool(): materials=materials, selected_materials=selected_materials, ) - except Exception as e: - traceback.print_exc() + 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..b2e89e8a --- /dev/null +++ b/docs/api.md @@ -0,0 +1,110 @@ +# OpenSpoolman JSON API + +Alle Endpoints liegen unter `/api` und geben folgendes Schema zurück: + +```json +// Erfolg +{ "success": true, "data": { ... } } + +// Fehler +{ "success": false, "error": { "code": "CODE", "message": "Details" } } +``` + +## Endpoints + +### GET /api/printers +Liefert die bekannte Druckerinstanz. +```json +{ + "success": true, + "data": [ + { "id": "PRINTER_ID", "name": "Friendly Name", "online": true, "last_seen": null } + ] +} +``` + +### GET /api/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/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/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/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/spoolman_service.py b/spoolman_service.py index b34e95ca..2ded7520 100644 --- a/spoolman_service.py +++ b/spoolman_service.py @@ -54,7 +54,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() diff --git a/tests/test_api_routes.py b/tests/test_api_routes.py new file mode 100644 index 00000000..2217f7df --- /dev/null +++ b/tests/test_api_routes.py @@ -0,0 +1,61 @@ +import os + +os.environ.setdefault("OPENSPOOLMAN_TEST_DATA", "1") +os.environ.setdefault("PRINTER_ID", "PRINTER_1") + +import json + +from app import app + + +def client(): + return app.test_client() + + +def test_get_printers_ok(): + resp = client().get("/api/printers") + assert resp.status_code == 200 + payload = resp.get_json() + assert payload["success"] is True + assert payload["data"][0]["id"] == "PRINTER_1" + + +def test_get_ams_slots_ok(): + resp = client().get("/api/printers/PRINTER_1/ams") + assert resp.status_code == 200 + payload = resp.get_json() + assert payload["success"] is True + assert "ams_slots" in payload["data"] + assert len(payload["data"]["ams_slots"]) > 0 + + +def test_spools_list_ok(): + resp = client().get("/api/spools") + assert resp.status_code == 200 + payload = resp.get_json() + assert payload["success"] is True + assert isinstance(payload["data"], list) + assert len(payload["data"]) > 0 + + +def test_assign_and_unassign_roundtrip(): + c = client() + + spools = c.get("/api/spools").get_json()["data"] + spool_id = spools[0]["id"] + + assign_resp = c.post( + "/api/printers/PRINTER_1/ams/1/assign", + data=json.dumps({"spool_id": spool_id, "ams_id": 0}), + content_type="application/json", + ) + assert assign_resp.status_code == 200 + assert assign_resp.get_json()["success"] is True + + unassign_resp = c.post( + "/api/printers/PRINTER_1/ams/1/unassign", + data=json.dumps({"spool_id": spool_id}), + content_type="application/json", + ) + assert unassign_resp.status_code == 200 + assert unassign_resp.get_json()["success"] is True From 5498d7da167cb72ca6b23c48609bd0ca8f371997 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 14 Dec 2025 16:33:51 +0100 Subject: [PATCH 02/12] api update --- api_routes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api_routes.py b/api_routes.py index 974bbc6a..b6f61e15 100644 --- a/api_routes.py +++ b/api_routes.py @@ -62,6 +62,7 @@ def _serialize_spool(spool: Dict[str, Any]) -> Dict[str, Any]: "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"), From 58fa6ea1d0c94f56bfc3a230c97ab373f0ca7b62 Mon Sep 17 00:00:00 2001 From: "Markus M." Date: Thu, 1 Jan 2026 17:38:16 +0100 Subject: [PATCH 03/12] moved color mismatch log to logs folder --- .github/ISSUE_TEMPLATE/filament-mismatch.md | 2 +- README.md | 4 ++-- agents.md | 4 ++-- spoolman_service.py | 19 +++++++++++-------- 4 files changed, 16 insertions(+), 13 deletions(-) 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/spoolman_service.py b/spoolman_service.py index d4af8efa..a672593c 100644 --- a/spoolman_service.py +++ b/spoolman_service.py @@ -140,9 +140,9 @@ 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_difference: float | None = None) -> 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" @@ -151,6 +151,7 @@ def _log_filament_mismatch(tray_data: dict, spool: dict) -> None: "timestamp": timestamp, "tray": tray_data, "spool": spool, + "color_difference": color_difference, }, f) except Exception: pass @@ -282,6 +283,10 @@ def _clean_basic(val: str) -> str: tray_data["issue"] = True break + color_difference = None + has_multi_color = "multi_color_hexes" in spool["filament"] + if not has_multi_color: + color_difference = color_distance(tray_data.get("tray_color"), tray_data["spool_color"]) spool_material_full_norm_cmp = _clean_basic(spool_material_full_norm) spool_type_norm_cmp = _clean_basic(spool_type_norm) if spool_type_norm else "" @@ -315,14 +320,12 @@ def _clean_basic(val: str) -> str: tray_data["mismatch"] = mismatch_detected and not DISABLE_MISMATCH_WARNING tray_data["issue"] = tray_data["mismatch"] if mismatch_detected: - _log_filament_mismatch(tray_data, spool) + _log_filament_mismatch(tray_data, spool, color_difference=color_difference) tray_data["matched"] = True - if "multi_color_hexes" not in spool["filament"]: - color_difference = color_distance(tray_data.get("tray_color"), tray_data["spool_color"] ) - 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." + 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." break From 8f87a1c935f9401115b0de937a4ea86337e71b62 Mon Sep 17 00:00:00 2001 From: "Markus M." Date: Thu, 1 Jan 2026 17:38:44 +0100 Subject: [PATCH 04/12] added display of used filament length in mm --- templates/fragments/list_prints.html | 64 ++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/templates/fragments/list_prints.html b/templates/fragments/list_prints.html index e3734a13..3d6ba1c0 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 %} +
From aad361dee5937e073988327d5fdef7fc6c0bbe1c Mon Sep 17 00:00:00 2001 From: "Markus M." Date: Thu, 1 Jan 2026 18:41:25 +0100 Subject: [PATCH 05/12] optimized display of large mm length --- templates/fragments/list_prints.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/fragments/list_prints.html b/templates/fragments/list_prints.html index 3d6ba1c0..b00cc49b 100644 --- a/templates/fragments/list_prints.html +++ b/templates/fragments/list_prints.html @@ -115,12 +115,12 @@ {% 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" %} + {% 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" %} + {% set expected_title = "Slicer metadata length: " + '{:,.0f}'.format(expected_length|float) + "mm" %} {% endif %}
@@ -172,7 +172,7 @@
#{{ filament['spool'].id }} – {{ filament['spool'].filament.vendor.name }} {% if expected_length is not none and (expected_length|float) > 0 %} - (~{{ '%.0f' | format(expected_length|float) }}mm) + (~{{ '{:,.0f}'.format(expected_length|float) }}mm) {% endif %} {% endif %} From ecff5c4113c95d0fa8350a29e73b69e35a02b958 Mon Sep 17 00:00:00 2001 From: "Markus M." Date: Thu, 1 Jan 2026 19:45:56 +0100 Subject: [PATCH 06/12] masked serialnumbers in mqtt.log --- mqtt_bambulab.py | 47 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 7 deletions(-) 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) From fba87790927919c459a3c56ae3b2db1a4221cdc8 Mon Sep 17 00:00:00 2001 From: "Markus M." Date: Fri, 2 Jan 2026 11:38:28 +0100 Subject: [PATCH 07/12] - logging of color difference added - color difference is only calculated when single color - active spool is always set and old spools are updated when trayid is duplicated set --- spoolman_service.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/spoolman_service.py b/spoolman_service.py index a672593c..f6c3fa46 100644 --- a/spoolman_service.py +++ b/spoolman_service.py @@ -140,9 +140,9 @@ 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, color_difference: float | None = None) -> None: +def _log_filament_mismatch(tray_data: dict, spool: dict) -> None: try: - data_path = Path("logs/filament_mismatch.json") + data_path = Path("data/filament_mismatch.json") data_path.parent.mkdir(parents=True, exist_ok=True) timestamp = datetime.utcnow().isoformat() + "Z" @@ -151,7 +151,6 @@ def _log_filament_mismatch(tray_data: dict, spool: dict, color_difference: float "timestamp": timestamp, "tray": tray_data, "spool": spool, - "color_difference": color_difference, }, f) except Exception: pass @@ -283,10 +282,6 @@ def _clean_basic(val: str) -> str: tray_data["issue"] = True break - color_difference = None - has_multi_color = "multi_color_hexes" in spool["filament"] - if not has_multi_color: - color_difference = color_distance(tray_data.get("tray_color"), tray_data["spool_color"]) spool_material_full_norm_cmp = _clean_basic(spool_material_full_norm) spool_type_norm_cmp = _clean_basic(spool_type_norm) if spool_type_norm else "" @@ -320,12 +315,14 @@ def _clean_basic(val: str) -> str: tray_data["mismatch"] = mismatch_detected and not DISABLE_MISMATCH_WARNING tray_data["issue"] = tray_data["mismatch"] if mismatch_detected: - _log_filament_mismatch(tray_data, spool, color_difference=color_difference) + _log_filament_mismatch(tray_data, spool) tray_data["matched"] = True - 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." + if "multi_color_hexes" not in spool["filament"]: + color_difference = color_distance(tray_data.get("tray_color"), tray_data["spool_color"] ) + 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." break @@ -414,13 +411,13 @@ def setActiveTray(spool_id, spool_extra, ams_id, tray_id): if spool_extra is None: spool_extra = {} - if not spool_extra.get("active_tray") or json.loads(spool_extra.get("active_tray")) != trayUid(ams_id, tray_id): + if not spool_extra.get("active_tray"): spoolman_client.patchExtraTags(spool_id, spool_extra, { "active_tray": json.dumps(trayUid(ams_id, tray_id)), }) # Remove active tray from inactive spools - for old_spool in fetchSpools(cached=True): + for old_spool in fetchSpools(cached=False): 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): spoolman_client.patchExtraTags(old_spool["id"], old_spool["extra"], {"active_tray": json.dumps("")}) else: From 7bb04190484e299a0ac8b3eb2eecfa6760e98363 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 2 Jan 2026 19:21:29 +0100 Subject: [PATCH 08/12] versioned api --- api_routes.py | 3 ++- docs/api.md | 13 ++++++------- tests/test_api_routes.py | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/api_routes.py b/api_routes.py index b6f61e15..280724ca 100644 --- a/api_routes.py +++ b/api_routes.py @@ -11,7 +11,8 @@ import test_data from config import EXTERNAL_SPOOL_AMS_ID, EXTERNAL_SPOOL_ID, PRINTER_ID, PRINTER_NAME -api_bp = Blueprint("api", __name__, url_prefix="/api") +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" diff --git a/docs/api.md b/docs/api.md index b2e89e8a..62612f3c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,6 +1,6 @@ # OpenSpoolman JSON API -Alle Endpoints liegen unter `/api` und geben folgendes Schema zurück: +Alle Endpoints liegen unter `/api/v1` und geben folgendes Schema zurück: ```json // Erfolg @@ -11,8 +11,7 @@ Alle Endpoints liegen unter `/api` und geben folgendes Schema zurück: ``` ## Endpoints - -### GET /api/printers +### GET `/api/v1/printers` Liefert die bekannte Druckerinstanz. ```json { @@ -23,7 +22,7 @@ Liefert die bekannte Druckerinstanz. } ``` -### GET /api/printers/{printer_id}/ams +### GET `/api/v1/printers/{printer_id}/ams` AMS-/Tray-Status für den Drucker. ```json { @@ -46,7 +45,7 @@ AMS-/Tray-Status für den Drucker. } ``` -### GET /api/spools +### GET `/api/v1/spools` Liste aller Spulen aus Spoolman. ```json { @@ -67,7 +66,7 @@ Liste aller Spulen aus Spoolman. } ``` -### POST /api/printers/{printer_id}/ams/{tray_index}/assign +### 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: @@ -89,7 +88,7 @@ Fehlerbeispiele: - 404 `SPOOL_NOT_FOUND` - 503 `PRINTER_OFFLINE` wenn MQTT-Verbindung fehlt -### POST /api/printers/{printer_id}/ams/{tray_index}/unassign +### 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: diff --git a/tests/test_api_routes.py b/tests/test_api_routes.py index 2217f7df..7588a475 100644 --- a/tests/test_api_routes.py +++ b/tests/test_api_routes.py @@ -13,7 +13,7 @@ def client(): def test_get_printers_ok(): - resp = client().get("/api/printers") + resp = client().get("/api/v1/printers") assert resp.status_code == 200 payload = resp.get_json() assert payload["success"] is True @@ -21,7 +21,7 @@ def test_get_printers_ok(): def test_get_ams_slots_ok(): - resp = client().get("/api/printers/PRINTER_1/ams") + resp = client().get("/api/v1/printers/PRINTER_1/ams") assert resp.status_code == 200 payload = resp.get_json() assert payload["success"] is True @@ -30,7 +30,7 @@ def test_get_ams_slots_ok(): def test_spools_list_ok(): - resp = client().get("/api/spools") + resp = client().get("/api/v1/spools") assert resp.status_code == 200 payload = resp.get_json() assert payload["success"] is True @@ -41,11 +41,11 @@ def test_spools_list_ok(): def test_assign_and_unassign_roundtrip(): c = client() - spools = c.get("/api/spools").get_json()["data"] + spools = c.get("/api/v1/spools").get_json()["data"] spool_id = spools[0]["id"] assign_resp = c.post( - "/api/printers/PRINTER_1/ams/1/assign", + "/api/v1/printers/PRINTER_1/ams/1/assign", data=json.dumps({"spool_id": spool_id, "ams_id": 0}), content_type="application/json", ) @@ -53,7 +53,7 @@ def test_assign_and_unassign_roundtrip(): assert assign_resp.get_json()["success"] is True unassign_resp = c.post( - "/api/printers/PRINTER_1/ams/1/unassign", + "/api/v1/printers/PRINTER_1/ams/1/unassign", data=json.dumps({"spool_id": spool_id}), content_type="application/json", ) From 7f656e20ab77a8cd39318319f34e7c583f50e629 Mon Sep 17 00:00:00 2001 From: Markus M Date: Fri, 2 Jan 2026 21:00:06 +0100 Subject: [PATCH 09/12] removed test --- tests/test_api_routes.py | 61 ---------------------------------------- 1 file changed, 61 deletions(-) delete mode 100644 tests/test_api_routes.py diff --git a/tests/test_api_routes.py b/tests/test_api_routes.py deleted file mode 100644 index 7588a475..00000000 --- a/tests/test_api_routes.py +++ /dev/null @@ -1,61 +0,0 @@ -import os - -os.environ.setdefault("OPENSPOOLMAN_TEST_DATA", "1") -os.environ.setdefault("PRINTER_ID", "PRINTER_1") - -import json - -from app import app - - -def client(): - return app.test_client() - - -def test_get_printers_ok(): - resp = client().get("/api/v1/printers") - assert resp.status_code == 200 - payload = resp.get_json() - assert payload["success"] is True - assert payload["data"][0]["id"] == "PRINTER_1" - - -def test_get_ams_slots_ok(): - resp = client().get("/api/v1/printers/PRINTER_1/ams") - assert resp.status_code == 200 - payload = resp.get_json() - assert payload["success"] is True - assert "ams_slots" in payload["data"] - assert len(payload["data"]["ams_slots"]) > 0 - - -def test_spools_list_ok(): - resp = client().get("/api/v1/spools") - assert resp.status_code == 200 - payload = resp.get_json() - assert payload["success"] is True - assert isinstance(payload["data"], list) - assert len(payload["data"]) > 0 - - -def test_assign_and_unassign_roundtrip(): - c = client() - - spools = c.get("/api/v1/spools").get_json()["data"] - spool_id = spools[0]["id"] - - assign_resp = c.post( - "/api/v1/printers/PRINTER_1/ams/1/assign", - data=json.dumps({"spool_id": spool_id, "ams_id": 0}), - content_type="application/json", - ) - assert assign_resp.status_code == 200 - assert assign_resp.get_json()["success"] is True - - unassign_resp = c.post( - "/api/v1/printers/PRINTER_1/ams/1/unassign", - data=json.dumps({"spool_id": spool_id}), - content_type="application/json", - ) - assert unassign_resp.status_code == 200 - assert unassign_resp.get_json()["success"] is True From 540aa4ac196601d0a797aa6dc9da042555e06f50 Mon Sep 17 00:00:00 2001 From: "Markus M." Date: Sat, 3 Jan 2026 09:10:29 +0100 Subject: [PATCH 10/12] - color mismatch is now logged correctly - fixed tray assignment bug --- spoolman_service.py | 40 ++++++++++++++++++++++++--------- tests/test_filament_mismatch.py | 30 ++++++++++++++++++++++--- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/spoolman_service.py b/spoolman_service.py index c07ccf65..181cce62 100644 --- a/spoolman_service.py +++ b/spoolman_service.py @@ -144,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 @@ -327,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 @@ -415,14 +433,14 @@ def setActiveTray(spool_id, spool_extra, ams_id, tray_id): if spool_extra is None: spool_extra = {} - if not spool_extra.get("active_tray"): + if not spool_extra.get("active_tray") or json.loads(spool_extra.get("active_tray")) != trayUid(ams_id, tray_id): spoolman_client.patchExtraTags(spool_id, spool_extra, { "active_tray": json.dumps(trayUid(ams_id, tray_id)), }) # Remove active tray from inactive spools for old_spool in fetchSpools(cached=False): - 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): + 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/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), From 4fa75dfc51c0c3d99fda3159ae74c2879a45f383 Mon Sep 17 00:00:00 2001 From: "Markus M." Date: Sat, 3 Jan 2026 17:16:30 +0100 Subject: [PATCH 11/12] fixed handling of local prints where url is an file:// path --- tools_3mf.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/tools_3mf.py b/tools_3mf.py index 39bb566d..f5019e29 100644 --- a/tools_3mf.py +++ b/tools_3mf.py @@ -69,7 +69,15 @@ def download3mfFromFTP(filename, destFile): ftp_host = PRINTER_IP ftp_user = "bblp" ftp_pass = PRINTER_CODE - remote_path = "/cache/" + filename + parsed = urlparse(filename) + if parsed.scheme in ("ftp", "ftps"): + remote_path = parsed.path or "/" + else: + remote_path = filename + + if not remote_path.startswith("/"): + remote_path = "/cache/" + remote_path + local_path = destFile.name # 🔹 Download into the current directory encoded_remote_path = urllib.parse.quote(remote_path) with open(local_path, "wb") as f: @@ -93,11 +101,20 @@ def download3mfFromFTP(filename, destFile): log("[DEBUG] Starting file download...") - try: + max_attempts = 3 + for attempt in range(1, max_attempts + 1): + try: + f.seek(0) + f.truncate() c.perform() log("[DEBUG] File successfully downloaded!") - except pycurl.error as e: - log(f"[ERROR] cURL error: {e}") + break + except pycurl.error as e: + log(f"[ERROR] cURL error on attempt {attempt}/{max_attempts}: {e}") + if attempt < max_attempts: + time.sleep(10) + else: + log("[ERROR] Giving up after repeated cURL failures.") c.close() @@ -126,6 +143,10 @@ def getMetaDataFrom3mf(url): download3mfFromCloud(url, temp_file) elif url.startswith("local:"): download3mfFromLocalFilesystem(url.replace("local:", ""), temp_file) + elif url.startswith("file://"): + # Handle file:// URLs from MQTT by fetching the path over FTP. + file_path = urlparse(url).path + download3mfFromFTP(file_path, temp_file) else: download3mfFromFTP(url.replace("ftp://", "").replace(".gcode",""), temp_file) From 653db83e88bcf6982b017594017a6ff5af9f2893 Mon Sep 17 00:00:00 2001 From: "Markus M." Date: Sun, 4 Jan 2026 15:15:21 +0100 Subject: [PATCH 12/12] Revert "fixed handling of local prints where url is an file:// path" This reverts commit 4fa75dfc51c0c3d99fda3159ae74c2879a45f383. Reverted commit because someone else fixed it --- tools_3mf.py | 29 ++++------------------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/tools_3mf.py b/tools_3mf.py index f5019e29..39bb566d 100644 --- a/tools_3mf.py +++ b/tools_3mf.py @@ -69,15 +69,7 @@ def download3mfFromFTP(filename, destFile): ftp_host = PRINTER_IP ftp_user = "bblp" ftp_pass = PRINTER_CODE - parsed = urlparse(filename) - if parsed.scheme in ("ftp", "ftps"): - remote_path = parsed.path or "/" - else: - remote_path = filename - - if not remote_path.startswith("/"): - remote_path = "/cache/" + remote_path - + remote_path = "/cache/" + filename local_path = destFile.name # 🔹 Download into the current directory encoded_remote_path = urllib.parse.quote(remote_path) with open(local_path, "wb") as f: @@ -101,20 +93,11 @@ def download3mfFromFTP(filename, destFile): log("[DEBUG] Starting file download...") - max_attempts = 3 - for attempt in range(1, max_attempts + 1): - try: - f.seek(0) - f.truncate() + try: c.perform() log("[DEBUG] File successfully downloaded!") - break - except pycurl.error as e: - log(f"[ERROR] cURL error on attempt {attempt}/{max_attempts}: {e}") - if attempt < max_attempts: - time.sleep(10) - else: - log("[ERROR] Giving up after repeated cURL failures.") + except pycurl.error as e: + log(f"[ERROR] cURL error: {e}") c.close() @@ -143,10 +126,6 @@ def getMetaDataFrom3mf(url): download3mfFromCloud(url, temp_file) elif url.startswith("local:"): download3mfFromLocalFilesystem(url.replace("local:", ""), temp_file) - elif url.startswith("file://"): - # Handle file:// URLs from MQTT by fetching the path over FTP. - file_path = urlparse(url).path - download3mfFromFTP(file_path, temp_file) else: download3mfFromFTP(url.replace("ftp://", "").replace(".gcode",""), temp_file)