spool_assignment_notifications.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. import logging
  2. from backend.app.core.database import async_session
  3. from backend.app.core.websocket import ws_manager
  4. from backend.app.models.printer import Printer
  5. from backend.app.models.spool_assignment import SpoolAssignment
  6. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  7. from backend.app.services.bambu_mqtt import PrinterState
  8. from backend.app.services.notification_service import notification_service
  9. from backend.app.services.printer_manager import printer_manager
  10. def _global_tray_from_assignment(ams_id: int, tray_id: int) -> int:
  11. """Convert an assignment tuple to Bambuddy global tray ID."""
  12. if ams_id in (254, 255):
  13. return 254 + tray_id
  14. if ams_id >= 128:
  15. return ams_id
  16. return ams_id * 4 + tray_id
  17. def _slot_label_from_global_tray(global_tray_id: int) -> str:
  18. """Return a human-readable slot label from a global tray ID."""
  19. if global_tray_id == 254:
  20. return "Ext-L"
  21. if global_tray_id == 255:
  22. return "Ext-R"
  23. if global_tray_id >= 128:
  24. return f"HT-{chr(65 + (global_tray_id - 128))}"
  25. # 24-27 = A2L AMS-Lite (normalised unit 6); see a2l-am-unit-16.
  26. if 24 <= global_tray_id <= 27:
  27. return f"Lite-{(global_tray_id % 4) + 1}"
  28. ams_id = global_tray_id // 4
  29. tray_id = global_tray_id % 4
  30. return f"{chr(65 + ams_id)}{tray_id + 1}"
  31. def _tray_profile_and_color_for_global_id(state: PrinterState | None, global_tray_id: int) -> tuple[str, str]:
  32. """Resolve expected tray material/profile and color for a global tray ID from current printer state."""
  33. if not state or not state.raw_data:
  34. return ("Unknown", "Unknown")
  35. ams_raw = state.raw_data.get("ams", {})
  36. ams_units = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  37. vt_trays = state.raw_data.get("vt_tray", [])
  38. if not isinstance(vt_trays, list):
  39. vt_trays = []
  40. for tray in vt_trays:
  41. if not isinstance(tray, dict):
  42. continue
  43. if int(tray.get("id", -1)) == global_tray_id:
  44. profile = tray.get("tray_sub_brands") or tray.get("tray_type") or "Unknown"
  45. color = tray.get("tray_color") or "Unknown"
  46. return (profile, color)
  47. for ams in ams_units:
  48. if not isinstance(ams, dict):
  49. continue
  50. ams_id = int(ams.get("id", -1))
  51. trays = ams.get("tray", [])
  52. if not isinstance(trays, list):
  53. continue
  54. for tray in trays:
  55. if not isinstance(tray, dict):
  56. continue
  57. tray_id = int(tray.get("id", -1))
  58. candidate = ams_id if ams_id >= 128 else (ams_id * 4 + tray_id)
  59. if candidate == global_tray_id:
  60. profile = tray.get("tray_sub_brands") or tray.get("tray_type") or "Unknown"
  61. color = tray.get("tray_color") or "Unknown"
  62. return (profile, color)
  63. return ("Unknown", "Unknown")
  64. def _decode_mqtt_mapping_to_global_trays(mapping_raw: object) -> list[int]:
  65. """Decode printer MQTT mapping values into Bambuddy global tray IDs."""
  66. if not isinstance(mapping_raw, list) or not mapping_raw:
  67. return []
  68. decoded: list[int] = []
  69. for value in mapping_raw:
  70. try:
  71. if isinstance(value, int):
  72. encoded = value
  73. elif isinstance(value, str):
  74. encoded = int(value, 10)
  75. else:
  76. continue
  77. except ValueError:
  78. continue
  79. if encoded >= 65535:
  80. continue
  81. ams_hw_id = (encoded >> 8) & 0xFF
  82. slot = encoded & 0xFF
  83. if 0 <= ams_hw_id <= 3:
  84. decoded.append(ams_hw_id * 4 + (slot & 0x03))
  85. elif 128 <= ams_hw_id <= 135:
  86. decoded.append(ams_hw_id)
  87. elif ams_hw_id in (254, 255):
  88. decoded.append(255 if slot == 255 else 254)
  89. return decoded
  90. async def notify_missing_spool_assignments_on_print_start(
  91. printer_id: int,
  92. data: dict,
  93. logger: logging.Logger,
  94. ) -> None:
  95. """Send notification when print-start mapping references unassigned trays."""
  96. explicit_mapping = data.get("ams_mapping")
  97. explicit_values = (
  98. [value for value in explicit_mapping if isinstance(value, int)] if isinstance(explicit_mapping, list) else []
  99. )
  100. raw_mapping = data.get("raw_data", {}).get("mapping") if isinstance(data.get("raw_data"), dict) else None
  101. decoded_values = _decode_mqtt_mapping_to_global_trays(raw_mapping)
  102. mapping_values = explicit_values if explicit_values else decoded_values
  103. used_global_trays = {value for value in mapping_values if value >= 0}
  104. if not used_global_trays:
  105. return
  106. try:
  107. async with async_session() as db:
  108. printer = await db.get(Printer, printer_id)
  109. printer_name = printer.name if printer else f"Printer {printer_id}"
  110. # A tray is "assigned" if it has a row in EITHER table: the legacy
  111. # spool_assignment table (internal-inventory mode) or
  112. # spoolman_slot_assignments (Spoolman mode — the binding
  113. # source-of-truth since #1119). Querying only the legacy table
  114. # flagged every used tray as missing on every Spoolman-mode print
  115. # (#1473). Both tables expose printer_id / ams_id / tray_id in the
  116. # same shape, so _global_tray_from_assignment works on either.
  117. legacy_rows = (
  118. await db.execute(SpoolAssignment.__table__.select().where(SpoolAssignment.printer_id == printer_id))
  119. ).fetchall()
  120. spoolman_rows = (
  121. await db.execute(
  122. SpoolmanSlotAssignment.__table__.select().where(SpoolmanSlotAssignment.printer_id == printer_id)
  123. )
  124. ).fetchall()
  125. assigned_global_trays = {
  126. _global_tray_from_assignment(row.ams_id, row.tray_id) for row in (*legacy_rows, *spoolman_rows)
  127. }
  128. missing_global = sorted(used_global_trays - assigned_global_trays)
  129. if not missing_global:
  130. return
  131. state = printer_manager.get_status(printer_id)
  132. missing_slots = []
  133. for global_id in missing_global:
  134. profile, color = _tray_profile_and_color_for_global_id(state, global_id)
  135. missing_slots.append(
  136. {
  137. "slot": _slot_label_from_global_tray(global_id),
  138. "profile": profile,
  139. "color": color,
  140. }
  141. )
  142. await ws_manager.send_missing_spool_assignment(
  143. printer_id=printer_id,
  144. printer_name=printer_name,
  145. missing_slots=missing_slots,
  146. )
  147. await notification_service.on_print_missing_spool_assignment(
  148. printer_id=printer_id,
  149. printer_name=printer_name,
  150. missing_slots=missing_slots,
  151. db=db,
  152. )
  153. except Exception as e:
  154. logger.warning("Missing spool-assignment notification failed: %s", e)