spool_assignment_notifications.py 9.0 KB

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