slot_nozzle.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. """Which nozzle does this AMS slot feed, and how wide is it?
  2. Every path that configures a slot needs the same two facts: the extruder the
  3. slot feeds, and that nozzle's diameter. Both the filament preset and the K
  4. profile are stored per nozzle diameter, so getting the diameter wrong silently
  5. selects the wrong preset *and* the wrong K value -- and before this module the
  6. answer was worked out independently in seven places, each with ``nozzles[0]``
  7. hard-coded as the diameter for every slot on the machine.
  8. ``nozzles[0]`` is correct on a single-nozzle printer and correct on a
  9. dual-nozzle printer with the same size fitted both sides, which is why it has
  10. survived. It is wrong the moment someone fits a 0.4 and a 0.2, which is exactly
  11. the machine this feature exists for.
  12. ## Which array index belongs to which extruder
  13. ``PrinterState.nozzles`` is filled by two different MQTT parsers that use
  14. opposite conventions, and this module is where that is resolved once:
  15. * The **H2/X2 path** (``bambu_mqtt`` ~5100) writes ``nozzles[nozzle["id"]]``
  16. straight from ``device.nozzle.info``, i.e. indexed by physical nozzle id.
  17. * The **legacy path** (~5013) writes left -> ``nozzles[0]``, right ->
  18. ``nozzles[1]``, which is the reverse of the extruder ids (extruder 0 is the
  19. RIGHT hotend).
  20. **MEASURED 2026-08-27 on an H2D with 0.4 high flow LEFT and 0.6 high flow
  21. RIGHT: ``nozzles[0]`` read 0.6 -- the right hotend, which is extruder 0.** So
  22. the array is indexed by extruder id, and the H2 convention (physical nozzle id N
  23. sits on extruder N) is the one that holds.
  24. The legacy branch cannot govern a real dual-nozzle machine anyway: every model
  25. in ``DUAL_NOZZLE_MODELS`` is H2-series or X2D, all of which report
  26. ``device.nozzle.info``, and ``left_nozzle_diameter`` appears nowhere in any
  27. captured log or wire trace. On a single-nozzle printer both conventions agree
  28. that index 0 is the only nozzle.
  29. The distinction is invisible on a machine with matching nozzles, since both
  30. conventions then return the same string -- which is why it went unnoticed for so
  31. long, and why this is the single place to change if a future model contradicts
  32. it.
  33. """
  34. from __future__ import annotations
  35. import logging
  36. from dataclasses import dataclass
  37. from backend.app.utils.fts_routing import slot_extruder
  38. from backend.app.utils.printer_models import is_dual_nozzle_model
  39. logger = logging.getLogger(__name__)
  40. # What a printer that has told us nothing is assumed to have fitted. Matches
  41. # the default every call site used before this module existed.
  42. DEFAULT_NOZZLE_DIAMETER = "0.4"
  43. @dataclass(frozen=True)
  44. class SlotNozzle:
  45. """The nozzle an AMS slot feeds."""
  46. # None when the printer has not said which extruder this slot feeds. Callers
  47. # that must have a number use ``extruder_or_default``; callers that store a
  48. # row keep the None so "unknown" is not written as "the right-hand nozzle".
  49. extruder: int | None
  50. diameter: str
  51. # "HH" (high flow), "HS" (standard), or None when the printer has not said.
  52. flow: str | None = None
  53. @property
  54. def extruder_or_default(self) -> int:
  55. """0 when unknown -- correct on a single-nozzle machine, a guess on a dual."""
  56. return 0 if self.extruder is None else self.extruder
  57. def flow_matches(self, stored_flow: str | None) -> bool:
  58. """Whether a stored K profile's flow type applies to this nozzle.
  59. Unknown on either side matches anything, and that is the load-bearing
  60. case rather than a nicety:
  61. * Every K profile stored before this existed has NULL here, so a strict
  62. comparison would stop applying all of them at once.
  63. * An X1C declares no flow on any calibration entry -- measured: all
  64. eight come back with ``nozzle_id: ''`` -- so profiles saved from one
  65. have nothing truthful to store. Treating "no answer" as "Standard"
  66. and then filtering on it would break the moment a high-flow nozzle is
  67. fitted to a machine whose table never mentioned flow.
  68. Once BOTH sides do declare one, they have to agree: a K value measured
  69. on a high-flow nozzle is not a fact about a standard one, the same way
  70. a 0.6 measurement says nothing about a 0.4.
  71. """
  72. if not stored_flow or not self.flow:
  73. return True
  74. return normalise_flow(stored_flow) == self.flow
  75. def normalise_flow(raw: str | None) -> str | None:
  76. """The flow-type code in a nozzle id or type string, or None.
  77. Both spellings reduce to the same two letters, which is the whole point:
  78. a calibration entry files its nozzle as ``HH00-0.4`` / ``HS00-0.4`` while
  79. the fitted nozzle reports its type as ``HH01`` -- measured on an H2D, and
  80. the reason this compares two characters rather than four. The trailing
  81. digits are a hardware variant the calibration table normalises to ``00``.
  82. """
  83. text = (raw or "").strip().upper()
  84. return text[:2] if text[:2] in ("HH", "HS") else None
  85. def reports_two_nozzles(state) -> bool:
  86. """Whether the live state shows a dual-nozzle printer.
  87. Decides which extruder an external spool feeds (254 = left = extruder 1,
  88. 255 = right = extruder 0). ``ams_extruder_map`` alone is not enough: it is
  89. derived from AMS info bits, so a dual-nozzle printer with no AMS reports an
  90. empty map, and its external spools then matched no nozzle (#2771).
  91. ``nozzles`` is always a two-entry list (the state seeds two empty stubs), so
  92. its length proves nothing; only a populated diameter on the second entry
  93. means real hardware. The other two signals cover firmware revisions that
  94. surface one but not the other: a populated ``ams_extruder_map`` is
  95. dual-nozzle by construction, and so is more than one ``vt_tray`` entry,
  96. since single-nozzle printers expose exactly one external feed.
  97. """
  98. raw_data = getattr(state, "raw_data", None) or {}
  99. nozzles = getattr(state, "nozzles", None) or []
  100. vt_trays = raw_data.get("vt_tray") or []
  101. return bool(
  102. (len(nozzles) > 1 and getattr(nozzles[1], "nozzle_diameter", ""))
  103. or raw_data.get("ams_extruder_map")
  104. # isinstance, because a dict here would count its keys as trays.
  105. or (isinstance(vt_trays, list) and len(vt_trays) > 1)
  106. )
  107. def nozzle_flow_for_extruder(state, extruder: int | None, model: str | None = None) -> str | None:
  108. """The flow type fitted to ``extruder``, or None when the printer is silent.
  109. Read from the same array as the diameter and indexed the same way. A
  110. printer that reports no nozzle type -- an X1C sends none at all -- yields
  111. None, which ``flow_matches`` treats as "applies to anything" rather than
  112. inventing Standard.
  113. """
  114. nozzles = getattr(state, "nozzles", None) or []
  115. if not nozzles:
  116. return None
  117. index = 0
  118. if extruder is not None and extruder > 0 and is_dual_nozzle_model(model):
  119. index = extruder
  120. for candidate in (index, 0):
  121. if candidate < len(nozzles):
  122. flow = normalise_flow(getattr(nozzles[candidate], "nozzle_type", ""))
  123. if flow:
  124. return flow
  125. return None
  126. def nozzle_diameter_for_extruder(state, extruder: int | None, model: str | None = None) -> str:
  127. """The diameter fitted to ``extruder``, or the printer's only nozzle.
  128. Falls back to index 0, and then to 0.4, whenever the printer has not
  129. reported the entry -- an absent nozzle must not make this raise, since it is
  130. called on every assign.
  131. """
  132. nozzles = getattr(state, "nozzles", None) or []
  133. if not nozzles:
  134. return DEFAULT_NOZZLE_DIAMETER
  135. index = 0
  136. if extruder is not None and extruder > 0 and is_dual_nozzle_model(model):
  137. # Physical nozzle id N sits on extruder N -- see the module docstring
  138. # for why the legacy left/right convention cannot apply here.
  139. index = extruder
  140. for candidate in (index, 0):
  141. if candidate < len(nozzles):
  142. diameter = (getattr(nozzles[candidate], "nozzle_diameter", "") or "").strip()
  143. if diameter:
  144. return diameter
  145. return DEFAULT_NOZZLE_DIAMETER
  146. def resolve_slot_nozzle(state, ams_id: int, tray_id: int, model: str | None = None) -> SlotNozzle:
  147. """The extruder an AMS slot feeds and that nozzle's diameter.
  148. ``state`` is the live ``PrinterState`` (or None when the printer is not
  149. connected, which yields the defaults rather than an error).
  150. """
  151. if state is None:
  152. return SlotNozzle(extruder=None, diameter=DEFAULT_NOZZLE_DIAMETER)
  153. extruder = slot_extruder(
  154. ams_id,
  155. tray_id,
  156. getattr(state, "ams_extruder_map", None),
  157. getattr(state, "ams_switch_inlet", None),
  158. )
  159. return SlotNozzle(
  160. extruder=extruder,
  161. diameter=nozzle_diameter_for_extruder(state, extruder, model),
  162. flow=nozzle_flow_for_extruder(state, extruder, model),
  163. )