spool_assignment_notifications.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  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.inventory_mode import spoolman_owns_assignments
  9. from backend.app.services.notification_service import notification_service
  10. from backend.app.services.printer_manager import printer_manager, printer_tray_layout
  11. from backend.app.utils.tray_ids import (
  12. EXTERNAL_DEPUTY_ID,
  13. AmsType,
  14. TrayLayout,
  15. decode_wire_slot,
  16. external_hotend,
  17. )
  18. def _global_tray_from_assignment(ams_id: int, tray_id: int, layout: TrayLayout | None = None) -> int:
  19. """Convert an assignment tuple to Bambuddy global tray ID."""
  20. if ams_id == EXTERNAL_DEPUTY_ID:
  21. # Older records keep the left external spool as its own ams_id.
  22. return EXTERNAL_DEPUTY_ID + tray_id
  23. return (layout or TrayLayout()).tray_from_slot_key(ams_id, tray_id)
  24. def _slot_label_from_global_tray(global_tray_id: int, layout: TrayLayout | None = None) -> str:
  25. """Return a human-readable slot label from a global tray ID."""
  26. layout = layout or TrayLayout()
  27. if layout.rack and layout.is_external(global_tray_id):
  28. # One external input per hotend, numbered like Bambu Studio's "Ext-n".
  29. return f"Ext-{external_hotend(global_tray_id) + 1}"
  30. if global_tray_id == 254:
  31. return "Ext-L"
  32. if global_tray_id == 255:
  33. return "Ext-R"
  34. if global_tray_id >= 128:
  35. return f"HT-{chr(65 + (global_tray_id - 128))}"
  36. # 24-27 = A2L AMS-Lite (normalised unit 6); see a2l-am-unit-16.
  37. if 24 <= global_tray_id <= 27:
  38. return f"Lite-{(global_tray_id % 4) + 1}"
  39. ams_id, tray_id = layout.slot_key(global_tray_id)
  40. kind = layout.ams_type(ams_id)
  41. if kind == AmsType.ZMULTI_A:
  42. return f"3H-{tray_id + 1}"
  43. if kind == AmsType.ZMULTI_B:
  44. return f"B-{tray_id + 1}"
  45. return f"{chr(65 + ams_id)}{tray_id + 1}"
  46. def _tray_profile_and_color_for_global_id(state: PrinterState | None, global_tray_id: int) -> tuple[str, str]:
  47. """Resolve expected tray material/profile and color for a global tray ID from current printer state."""
  48. if not state or not state.raw_data:
  49. return ("Unknown", "Unknown")
  50. ams_raw = state.raw_data.get("ams", {})
  51. ams_units = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
  52. layout = TrayLayout.from_state(state)
  53. vt_trays = state.raw_data.get("vt_tray", [])
  54. if not isinstance(vt_trays, list):
  55. vt_trays = []
  56. for tray in vt_trays:
  57. if not isinstance(tray, dict):
  58. continue
  59. if int(tray.get("id", -1)) == 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. for ams in ams_units:
  64. if not isinstance(ams, dict):
  65. continue
  66. ams_id = int(ams.get("id", -1))
  67. trays = ams.get("tray", [])
  68. if not isinstance(trays, list):
  69. continue
  70. for tray in trays:
  71. if not isinstance(tray, dict):
  72. continue
  73. tray_id = int(tray.get("id", -1))
  74. if layout.global_tray_id(ams_id, tray_id) == global_tray_id:
  75. profile = tray.get("tray_sub_brands") or tray.get("tray_type") or "Unknown"
  76. color = tray.get("tray_color") or "Unknown"
  77. return (profile, color)
  78. return ("Unknown", "Unknown")
  79. def _decode_mqtt_mapping_to_global_trays(mapping_raw: object, layout: TrayLayout | None = None) -> list[int]:
  80. """Decode printer MQTT mapping values into Bambuddy global tray IDs."""
  81. if not isinstance(mapping_raw, list) or not mapping_raw:
  82. return []
  83. decoded = (decode_wire_slot(value, layout) for value in mapping_raw)
  84. return [tray for tray in decoded if tray is not None]
  85. async def notify_missing_spool_assignments_on_print_start(
  86. printer_id: int,
  87. data: dict,
  88. logger: logging.Logger,
  89. ) -> None:
  90. """Send notification when print-start mapping references unassigned trays."""
  91. explicit_mapping = data.get("ams_mapping")
  92. explicit_values = (
  93. [value for value in explicit_mapping if isinstance(value, int)] if isinstance(explicit_mapping, list) else []
  94. )
  95. raw_mapping = data.get("raw_data", {}).get("mapping") if isinstance(data.get("raw_data"), dict) else None
  96. layout = printer_tray_layout(printer_id, printer_manager)
  97. decoded_values = _decode_mqtt_mapping_to_global_trays(raw_mapping, layout)
  98. mapping_values = explicit_values if explicit_values else decoded_values
  99. used_global_trays = {value for value in mapping_values if value >= 0}
  100. if not used_global_trays:
  101. return
  102. try:
  103. async with async_session() as db:
  104. printer = await db.get(Printer, printer_id)
  105. printer_name = printer.name if printer else f"Printer {printer_id}"
  106. # A tray is "assigned" if it has a row in the table the current
  107. # mode uses. Both expose printer_id / ams_id / tray_id in the same
  108. # shape, so _global_tray_from_assignment works on either.
  109. #
  110. # This read both tables and unioned them until #2812. That was
  111. # correct while the inactive table was emptied on every mode
  112. # toggle -- it is how #1473 was fixed, where querying only the
  113. # legacy table flagged every tray as missing on a Spoolman print.
  114. # Nothing is emptied now, so a union would let a leftover row in
  115. # the mode you are *not* using vouch for a tray that has no
  116. # assignment in the mode you are, and this notification exists
  117. # precisely to catch that tray.
  118. table = SpoolmanSlotAssignment if await spoolman_owns_assignments(db) else SpoolAssignment
  119. rows = (await db.execute(table.__table__.select().where(table.printer_id == printer_id))).fetchall()
  120. assigned_global_trays = {_global_tray_from_assignment(row.ams_id, row.tray_id, layout) for row in rows}
  121. missing_global = sorted(used_global_trays - assigned_global_trays)
  122. if not missing_global:
  123. return
  124. await _send_missing_assignment_notification(printer_id, printer_name, missing_global, db)
  125. except Exception as e:
  126. logger.warning("Missing spool-assignment notification failed: %s", e)
  127. async def _send_missing_assignment_notification(
  128. printer_id: int,
  129. printer_name: str,
  130. missing_global: list[int],
  131. db,
  132. ) -> None:
  133. """Describe the unassigned trays and push them to the UI and the providers."""
  134. state = printer_manager.get_status(printer_id)
  135. layout = TrayLayout.from_state(state)
  136. missing_slots = []
  137. for global_id in missing_global:
  138. profile, color = _tray_profile_and_color_for_global_id(state, global_id)
  139. missing_slots.append(
  140. {
  141. "slot": _slot_label_from_global_tray(global_id, layout),
  142. "profile": profile,
  143. "color": color,
  144. }
  145. )
  146. await ws_manager.send_missing_spool_assignment(
  147. printer_id=printer_id,
  148. printer_name=printer_name,
  149. missing_slots=missing_slots,
  150. )
  151. await notification_service.on_print_missing_spool_assignment(
  152. printer_id=printer_id,
  153. printer_name=printer_name,
  154. missing_slots=missing_slots,
  155. db=db,
  156. )
  157. async def notify_missing_spool_assignments_on_print_complete(
  158. printer_id: int,
  159. missing_global_trays: list[int],
  160. db,
  161. logger: logging.Logger,
  162. ) -> None:
  163. """Say so when a finished print could not debit a tray it drew from (#2812).
  164. The print-start check above is predictive: it reads the mapping before the
  165. job runs and warns about trays that have no assignment yet. It cannot cover
  166. an assignment that disappears *during* a print, and nothing re-checked
  167. afterwards -- so a print whose assignments existed at print start, and were
  168. gone by the time it finished, resolved its 3MF, resolved its grams,
  169. resolved its tray, skipped the debit at INFO, and reported success. The
  170. reporter lost 65.49 g that way and only noticed because a spool's remaining
  171. weight looked wrong.
  172. This fires on realized loss rather than risk: the trays passed here are the
  173. ones a completed print actually tried to charge and could not. A print that
  174. was already warned at start will notify twice, which is the right trade --
  175. the first says the weight may not be tracked, the second says it was not.
  176. Takes the caller's session: this runs inside ``on_print_complete``'s
  177. transaction, and opening a second one to read the printer's name would
  178. deadlock against it on SQLite.
  179. """
  180. if not missing_global_trays:
  181. return
  182. try:
  183. printer = await db.get(Printer, printer_id)
  184. printer_name = printer.name if printer else f"Printer {printer_id}"
  185. await _send_missing_assignment_notification(printer_id, printer_name, sorted(set(missing_global_trays)), db)
  186. except Exception as e: # noqa: BLE001 — a notification must not fail a completed print
  187. logger.warning("Missing spool-assignment completion notification failed: %s", e)