|
| 1 | +import json |
| 2 | +import os |
| 3 | +import traceback |
| 4 | +from typing import Any, Dict, List, Optional, Tuple |
| 5 | + |
| 6 | +from flask import Blueprint, jsonify, request |
| 7 | + |
| 8 | +import mqtt_bambulab |
| 9 | +import spoolman_client |
| 10 | +import spoolman_service |
| 11 | +import test_data |
| 12 | +from config import EXTERNAL_SPOOL_AMS_ID, EXTERNAL_SPOOL_ID, PRINTER_ID, PRINTER_NAME |
| 13 | + |
| 14 | +API_VERSION = "v1" |
| 15 | +api_bp = Blueprint("api", __name__, url_prefix=f"/api/{API_VERSION}") |
| 16 | + |
| 17 | +READ_ONLY_MODE = (not test_data.test_data_active()) and os.getenv("OPENSPOOLMAN_LIVE_READONLY") == "1" |
| 18 | +ACTIVE_PRINTER_ID = (PRINTER_ID or "").upper() or "PRINTER_1" |
| 19 | + |
| 20 | + |
| 21 | +def json_success(data: Any, status: int = 200): |
| 22 | + return jsonify({"success": True, "data": data}), status |
| 23 | + |
| 24 | + |
| 25 | +def json_error(code: str, message: str, status: int = 400): |
| 26 | + return jsonify({"success": False, "error": {"code": code, "message": message}}), status |
| 27 | + |
| 28 | + |
| 29 | +def _printer_matches(printer_id: str) -> bool: |
| 30 | + return str(printer_id or "").upper() == ACTIVE_PRINTER_ID |
| 31 | + |
| 32 | + |
| 33 | +def _clean_json_value(value: Any) -> Any: |
| 34 | + if isinstance(value, str): |
| 35 | + try: |
| 36 | + return json.loads(value) |
| 37 | + except Exception: |
| 38 | + return value |
| 39 | + return value |
| 40 | + |
| 41 | + |
| 42 | +def _serialize_spool(spool: Dict[str, Any]) -> Dict[str, Any]: |
| 43 | + filament = spool.get("filament", {}) or {} |
| 44 | + extra = spool.get("extra", {}) or {} |
| 45 | + |
| 46 | + tag = _clean_json_value(extra.get("tag")) |
| 47 | + |
| 48 | + assigned_ams_id = None |
| 49 | + assigned_tray_index = None |
| 50 | + active_tray = extra.get("active_tray") |
| 51 | + if active_tray: |
| 52 | + try: |
| 53 | + tray_uid = json.loads(active_tray) |
| 54 | + parts = tray_uid.split("_") |
| 55 | + if len(parts) >= 2: |
| 56 | + assigned_tray_index = int(parts[-1]) |
| 57 | + assigned_ams_id = int(parts[-2]) |
| 58 | + except Exception: |
| 59 | + assigned_ams_id = None |
| 60 | + assigned_tray_index = None |
| 61 | + |
| 62 | + return { |
| 63 | + "id": str(spool.get("id")), |
| 64 | + "name": filament.get("name") or spool.get("name") or f"Spool {spool.get('id')}", |
| 65 | + "material": filament.get("material") or "", |
| 66 | + "vendor": (filament.get("vendor") or {}).get("name"), |
| 67 | + "color": filament.get("multi_color_hexes") or filament.get("color_hex") or "", |
| 68 | + "diameter_mm": filament.get("diameter"), |
| 69 | + "weight_g": spool.get("initial_weight") or filament.get("weight"), |
| 70 | + "remaining_g": spool.get("remaining_weight"), |
| 71 | + "tag": tag, |
| 72 | + "location": spool.get("location"), |
| 73 | + "ams_id": assigned_ams_id, |
| 74 | + "tray_index": assigned_tray_index, |
| 75 | + } |
| 76 | + |
| 77 | + |
| 78 | +def _find_spool_for_tray(spools: List[Dict[str, Any]], ams_id: int, tray_id: int) -> Optional[Dict[str, Any]]: |
| 79 | + tray_uid = spoolman_service.trayUid(ams_id, tray_id) |
| 80 | + for spool in spools: |
| 81 | + active = _clean_json_value((spool.get("extra") or {}).get("active_tray")) |
| 82 | + if active and active == tray_uid: |
| 83 | + return spool |
| 84 | + return None |
| 85 | + |
| 86 | + |
| 87 | +def _serialize_tray(tray: Dict[str, Any], spools: List[Dict[str, Any]], ams_id: int) -> Dict[str, Any]: |
| 88 | + tray_id = int(tray.get("id") or 0) |
| 89 | + matched_spool = _find_spool_for_tray(spools, ams_id, tray_id) |
| 90 | + |
| 91 | + filament = matched_spool.get("filament", {}) if matched_spool else {} |
| 92 | + spool_name = filament.get("name") if matched_spool else None |
| 93 | + spool_id = matched_spool.get("id") if matched_spool else None |
| 94 | + vendor = (filament.get("vendor") or {}).get("name") |
| 95 | + material = filament.get("material") or tray.get("tray_type") or "" |
| 96 | + |
| 97 | + tray_color_raw = tray.get("tray_color") or "" |
| 98 | + tray_color = spoolman_service.normalize_color_hex(tray_color_raw) |
| 99 | + tray_color_value = f"#{tray_color}" if tray_color else "" |
| 100 | + |
| 101 | + spool_color_value = "" |
| 102 | + color_mismatch = False |
| 103 | + color_mismatch_message = "" |
| 104 | + has_multi_color = False |
| 105 | + raw_multi_color = filament.get("multi_color_hexes") |
| 106 | + if raw_multi_color: |
| 107 | + has_multi_color = True |
| 108 | + first_color = None |
| 109 | + if isinstance(raw_multi_color, list): |
| 110 | + first_color = raw_multi_color[0] if raw_multi_color else None |
| 111 | + else: |
| 112 | + first_color = str(raw_multi_color).split(",")[0] |
| 113 | + normalized = spoolman_service.normalize_color_hex(first_color or "") |
| 114 | + if normalized: |
| 115 | + spool_color_value = f"#{normalized}" |
| 116 | + else: |
| 117 | + normalized = spoolman_service.normalize_color_hex(filament.get("color_hex") or "") |
| 118 | + if normalized: |
| 119 | + spool_color_value = f"#{normalized}" |
| 120 | + |
| 121 | + if not has_multi_color and tray_color_value and spool_color_value: |
| 122 | + distance = spoolman_service.color_distance(tray_color_value, spool_color_value) |
| 123 | + if distance is not None and distance > spoolman_service.COLOR_DISTANCE_TOLERANCE: |
| 124 | + color_mismatch = True |
| 125 | + color_mismatch_message = "Colors are not similar." |
| 126 | + |
| 127 | + color_value = spool_color_value or tray_color_value |
| 128 | + active = bool(tray.get("state") == 3 or matched_spool) |
| 129 | + is_loaded = bool(tray.get("remain")) or bool(matched_spool) |
| 130 | + remaining_g = None |
| 131 | + if matched_spool: |
| 132 | + remaining_g = matched_spool.get("remaining_weight") |
| 133 | + if remaining_g is None: |
| 134 | + remaining_g = matched_spool.get("remain") |
| 135 | + else: |
| 136 | + remaining_g = tray.get("remain") |
| 137 | + |
| 138 | + return { |
| 139 | + "index": tray_id, |
| 140 | + "ams_id": ams_id, |
| 141 | + "spool_id": spool_id, |
| 142 | + "spool_name": spool_name, |
| 143 | + "material": material, |
| 144 | + "color": color_value, |
| 145 | + "tray_color": tray_color_value, |
| 146 | + "spool_color": spool_color_value, |
| 147 | + "color_mismatch": color_mismatch, |
| 148 | + "color_mismatch_message": color_mismatch_message, |
| 149 | + "spool_vendor": vendor, |
| 150 | + "remaining_g": remaining_g, |
| 151 | + "active": active, |
| 152 | + "is_loaded": is_loaded, |
| 153 | + } |
| 154 | + |
| 155 | + |
| 156 | +def _load_printer_summary() -> Dict[str, Any]: |
| 157 | + model = mqtt_bambulab.getPrinterModel() |
| 158 | + name = PRINTER_NAME or model.get("devicename") or "Printer" |
| 159 | + |
| 160 | + return { |
| 161 | + "id": ACTIVE_PRINTER_ID, |
| 162 | + "name": name, |
| 163 | + "online": mqtt_bambulab.isMqttClientConnected(), |
| 164 | + "last_seen": None, |
| 165 | + } |
| 166 | + |
| 167 | + |
| 168 | +def _load_trays() -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: |
| 169 | + config = mqtt_bambulab.getLastAMSConfig() or {} |
| 170 | + spools = mqtt_bambulab.fetchSpools() |
| 171 | + |
| 172 | + trays: List[Dict[str, Any]] = [] |
| 173 | + |
| 174 | + vt_tray = config.get("vt_tray") |
| 175 | + if vt_tray: |
| 176 | + trays.append(_serialize_tray(vt_tray, spools, EXTERNAL_SPOOL_AMS_ID)) |
| 177 | + |
| 178 | + for ams in config.get("ams", []): |
| 179 | + ams_id = int(ams.get("id", 0)) |
| 180 | + for tray in ams.get("tray", []): |
| 181 | + trays.append(_serialize_tray(tray, spools, ams_id)) |
| 182 | + |
| 183 | + return trays, config |
| 184 | + |
| 185 | + |
| 186 | +def _resolve_tray_context(tray_index: int) -> Tuple[Optional[int], Optional[int]]: |
| 187 | + config = mqtt_bambulab.getLastAMSConfig() or {} |
| 188 | + |
| 189 | + vt_tray = config.get("vt_tray") |
| 190 | + if vt_tray and int(vt_tray.get("id", -1)) == tray_index: |
| 191 | + return EXTERNAL_SPOOL_AMS_ID, tray_index |
| 192 | + |
| 193 | + for ams in config.get("ams", []): |
| 194 | + ams_id = int(ams.get("id", -1)) |
| 195 | + for tray in ams.get("tray", []): |
| 196 | + if int(tray.get("id", -1)) == tray_index: |
| 197 | + return ams_id, tray_index |
| 198 | + |
| 199 | + return None, None |
| 200 | + |
| 201 | + |
| 202 | +@api_bp.route("/printers", methods=["GET"]) |
| 203 | +def api_list_printers(): |
| 204 | + try: |
| 205 | + printer = _load_printer_summary() |
| 206 | + return json_success([printer]) |
| 207 | + except Exception as exc: |
| 208 | + traceback.print_exc() |
| 209 | + return json_error("PRINTER_FETCH_FAILED", f"Failed to load printer info: {exc}", 500) |
| 210 | + |
| 211 | + |
| 212 | +@api_bp.route("/printers/<printer_id>/ams", methods=["GET"]) |
| 213 | +def api_get_ams(printer_id: str): |
| 214 | + if not _printer_matches(printer_id): |
| 215 | + return json_error("PRINTER_NOT_FOUND", f"Printer '{printer_id}' not found", 404) |
| 216 | + |
| 217 | + try: |
| 218 | + trays, _ = _load_trays() |
| 219 | + payload = {"printer_id": ACTIVE_PRINTER_ID, "ams_slots": trays} |
| 220 | + return json_success(payload) |
| 221 | + except Exception as exc: |
| 222 | + traceback.print_exc() |
| 223 | + return json_error("AMS_FETCH_FAILED", f"Failed to fetch AMS data: {exc}", 500) |
| 224 | + |
| 225 | + |
| 226 | +@api_bp.route("/spools", methods=["GET"]) |
| 227 | +def api_get_spools(): |
| 228 | + try: |
| 229 | + spools = spoolman_service.fetchSpools() |
| 230 | + return json_success([_serialize_spool(spool) for spool in spools]) |
| 231 | + except Exception as exc: |
| 232 | + traceback.print_exc() |
| 233 | + return json_error("SPOOL_FETCH_FAILED", f"Failed to fetch spools: {exc}", 500) |
| 234 | + |
| 235 | + |
| 236 | +@api_bp.route("/printers/<printer_id>/ams/<int:tray_index>/assign", methods=["POST"]) |
| 237 | +def api_assign_tray(printer_id: str, tray_index: int): |
| 238 | + if not _printer_matches(printer_id): |
| 239 | + return json_error("PRINTER_NOT_FOUND", f"Printer '{printer_id}' not found", 404) |
| 240 | + |
| 241 | + if READ_ONLY_MODE: |
| 242 | + return json_error("READ_ONLY_MODE", "Live read-only mode: assigning spools to trays is disabled.", 403) |
| 243 | + |
| 244 | + if not mqtt_bambulab.isMqttClientConnected(): |
| 245 | + return json_error("PRINTER_OFFLINE", "MQTT is disconnected. Is the printer online?", 503) |
| 246 | + |
| 247 | + body = request.get_json(silent=True) or {} |
| 248 | + spool_id = body.get("spool_id") |
| 249 | + |
| 250 | + if not spool_id: |
| 251 | + return json_error("INVALID_REQUEST", "Field 'spool_id' is required.", 400) |
| 252 | + |
| 253 | + ams_id = body.get("ams_id") |
| 254 | + if ams_id is None: |
| 255 | + ams_id, resolved_tray = _resolve_tray_context(tray_index) |
| 256 | + if resolved_tray is None: |
| 257 | + return json_error("TRAY_NOT_FOUND", f"Tray '{tray_index}' not found", 404) |
| 258 | + else: |
| 259 | + try: |
| 260 | + ams_id = int(ams_id) |
| 261 | + except (TypeError, ValueError): |
| 262 | + return json_error("INVALID_REQUEST", "ams_id must be an integer when provided.", 400) |
| 263 | + resolved_tray = tray_index |
| 264 | + |
| 265 | + try: |
| 266 | + spool_data = spoolman_client.getSpoolById(spool_id) |
| 267 | + except Exception as exc: |
| 268 | + traceback.print_exc() |
| 269 | + return json_error("SPOOL_FETCH_FAILED", f"Failed to fetch spool '{spool_id}': {exc}", 502) |
| 270 | + |
| 271 | + if not spool_data or spool_data.get("id") is None: |
| 272 | + return json_error("SPOOL_NOT_FOUND", f"Spool '{spool_id}' not found", 404) |
| 273 | + |
| 274 | + try: |
| 275 | + mqtt_bambulab.setActiveTray(spool_id, spool_data.get("extra"), ams_id, resolved_tray) |
| 276 | + |
| 277 | + # Reuse the existing assignment logic from app.setActiveSpool to keep behavior aligned with /fill. |
| 278 | + from app import setActiveSpool # Local import to avoid circular dependency at module load time |
| 279 | + setActiveSpool(ams_id, resolved_tray, spool_data) |
| 280 | + except Exception as exc: |
| 281 | + traceback.print_exc() |
| 282 | + return json_error("ASSIGN_FAILED", f"Failed to assign spool '{spool_id}' to tray '{tray_index}': {exc}", 500) |
| 283 | + |
| 284 | + return json_success({"printer_id": ACTIVE_PRINTER_ID, "tray_index": tray_index, "ams_id": ams_id, "spool_id": spool_id}) |
| 285 | + |
| 286 | + |
| 287 | +@api_bp.route("/printers/<printer_id>/ams/<int:tray_index>/unassign", methods=["POST"]) |
| 288 | +def api_unassign_tray(printer_id: str, tray_index: int): |
| 289 | + if not _printer_matches(printer_id): |
| 290 | + return json_error("PRINTER_NOT_FOUND", f"Printer '{printer_id}' not found", 404) |
| 291 | + |
| 292 | + if READ_ONLY_MODE: |
| 293 | + return json_error("READ_ONLY_MODE", "Live read-only mode: assigning spools to trays is disabled.", 403) |
| 294 | + |
| 295 | + body = request.get_json(silent=True) or {} |
| 296 | + spool_id = body.get("spool_id") |
| 297 | + |
| 298 | + try: |
| 299 | + spool: Optional[Dict[str, Any]] = None |
| 300 | + if spool_id: |
| 301 | + spool = spoolman_client.getSpoolById(spool_id) |
| 302 | + else: |
| 303 | + spools = spoolman_service.fetchSpools() |
| 304 | + ams_id, _ = _resolve_tray_context(tray_index) |
| 305 | + if ams_id is None: |
| 306 | + return json_error("TRAY_NOT_FOUND", f"Tray '{tray_index}' not found", 404) |
| 307 | + spool = _find_spool_for_tray(spools, ams_id, tray_index) |
| 308 | + |
| 309 | + if not spool or spool.get("id") is None: |
| 310 | + return json_error("SPOOL_NOT_FOUND", "No spool assigned to this tray", 404) |
| 311 | + |
| 312 | + extras = spool.get("extra") or {} |
| 313 | + spoolman_client.patchExtraTags(spool["id"], extras, {"active_tray": ""}) |
| 314 | + return json_success( |
| 315 | + { |
| 316 | + "printer_id": ACTIVE_PRINTER_ID, |
| 317 | + "tray_index": tray_index, |
| 318 | + "spool_id": spool["id"], |
| 319 | + "unassigned": True, |
| 320 | + } |
| 321 | + ) |
| 322 | + except Exception as exc: |
| 323 | + traceback.print_exc() |
| 324 | + return json_error("UNASSIGN_FAILED", f"Failed to unassign tray: {exc}", 500) |
0 commit comments