slot_nozzle.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  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. from backend.app.utils.tray_ids import TrayLayout
  40. logger = logging.getLogger(__name__)
  41. # What a printer that has told us nothing is assumed to have fitted. Matches
  42. # the default every call site used before this module existed.
  43. DEFAULT_NOZZLE_DIAMETER = "0.4"
  44. @dataclass(frozen=True)
  45. class SlotNozzle:
  46. """The nozzle an AMS slot feeds."""
  47. # None when the printer has not said which extruder this slot feeds. Callers
  48. # that must have a number use ``extruder_or_default``; callers that store a
  49. # row keep the None so "unknown" is not written as "the right-hand nozzle".
  50. extruder: int | None
  51. diameter: str
  52. # "HH" (high flow), "HS" (standard), or None when the printer has not said.
  53. flow: str | None = None
  54. @property
  55. def extruder_or_default(self) -> int:
  56. """0 when unknown -- correct on a single-nozzle machine, a guess on a dual."""
  57. return 0 if self.extruder is None else self.extruder
  58. def flow_matches(self, stored_flow: str | None) -> bool:
  59. """Whether a stored K profile's flow type applies to this nozzle.
  60. Unknown on either side matches anything, and that is the load-bearing
  61. case rather than a nicety:
  62. * Every K profile stored before this existed has NULL here, so a strict
  63. comparison would stop applying all of them at once.
  64. * An X1C declares no flow on any calibration entry -- measured: all
  65. eight come back with ``nozzle_id: ''`` -- so profiles saved from one
  66. have nothing truthful to store. Treating "no answer" as "Standard"
  67. and then filtering on it would break the moment a high-flow nozzle is
  68. fitted to a machine whose table never mentioned flow.
  69. Once BOTH sides do declare one, they have to agree: a K value measured
  70. on a high-flow nozzle is not a fact about a standard one, the same way
  71. a 0.6 measurement says nothing about a 0.4.
  72. """
  73. if not stored_flow or not self.flow:
  74. return True
  75. return normalise_flow(stored_flow) == self.flow
  76. def normalise_flow(raw: str | None) -> str | None:
  77. """The flow-type code in a nozzle id or type string, or None.
  78. Both spellings reduce to the same two letters, which is the whole point:
  79. a calibration entry files its nozzle as ``HH00-0.4`` / ``HS00-0.4`` while
  80. the fitted nozzle reports its type as ``HH01`` -- measured on an H2D, and
  81. the reason this compares two characters rather than four. The trailing
  82. digits are a hardware variant the calibration table normalises to ``00``.
  83. """
  84. text = (raw or "").strip().upper()
  85. return text[:2] if text[:2] in ("HH", "HS") else None
  86. def reports_two_nozzles(state) -> bool:
  87. """Whether the live state shows a dual-nozzle printer.
  88. Decides which extruder an external spool feeds (254 = left = extruder 1,
  89. 255 = right = extruder 0). ``ams_extruder_map`` alone is not enough: it is
  90. derived from AMS info bits, so a dual-nozzle printer with no AMS reports an
  91. empty map, and its external spools then matched no nozzle (#2771).
  92. ``nozzles`` is always a two-entry list (the state seeds two empty stubs), so
  93. its length proves nothing; only a populated diameter on the second entry
  94. means real hardware. The other two signals cover firmware revisions that
  95. surface one but not the other: a populated ``ams_extruder_map`` is
  96. dual-nozzle by construction, and so is more than one ``vt_tray`` entry,
  97. since single-nozzle printers expose exactly one external feed.
  98. """
  99. raw_data = getattr(state, "raw_data", None) or {}
  100. nozzles = getattr(state, "nozzles", None) or []
  101. vt_trays = raw_data.get("vt_tray") or []
  102. return bool(
  103. (len(nozzles) > 1 and getattr(nozzles[1], "nozzle_diameter", ""))
  104. or raw_data.get("ams_extruder_map")
  105. # isinstance, because a dict here would count its keys as trays. An
  106. # H3C's one input per hotend is not a second extruder.
  107. or (isinstance(vt_trays, list) and len(vt_trays) > 1 and not TrayLayout.from_units(None, vt_trays).rack)
  108. )
  109. def nozzle_flow_for_extruder(state, extruder: int | None, model: str | None = None) -> str | None:
  110. """The flow type fitted to ``extruder``, or None when the printer is silent.
  111. Read from the same array as the diameter and indexed the same way. A
  112. printer that reports no nozzle type -- an X1C sends none at all -- yields
  113. None, which ``flow_matches`` treats as "applies to anything" rather than
  114. inventing Standard.
  115. """
  116. nozzles = getattr(state, "nozzles", None) or []
  117. if not nozzles:
  118. return None
  119. index = 0
  120. if extruder is not None and extruder > 0 and is_dual_nozzle_model(model):
  121. index = extruder
  122. for candidate in (index, 0):
  123. if candidate < len(nozzles):
  124. flow = normalise_flow(getattr(nozzles[candidate], "nozzle_type", ""))
  125. if flow:
  126. return flow
  127. return None
  128. def nozzle_diameter_for_extruder(state, extruder: int | None, model: str | None = None) -> str:
  129. """The diameter fitted to ``extruder``, or the printer's only nozzle.
  130. Falls back to index 0, and then to 0.4, whenever the printer has not
  131. reported the entry -- an absent nozzle must not make this raise, since it is
  132. called on every assign.
  133. """
  134. nozzles = getattr(state, "nozzles", None) or []
  135. if not nozzles:
  136. return DEFAULT_NOZZLE_DIAMETER
  137. index = 0
  138. if extruder is not None and extruder > 0 and is_dual_nozzle_model(model):
  139. # Physical nozzle id N sits on extruder N -- see the module docstring
  140. # for why the legacy left/right convention cannot apply here.
  141. index = extruder
  142. for candidate in (index, 0):
  143. if candidate < len(nozzles):
  144. diameter = (getattr(nozzles[candidate], "nozzle_diameter", "") or "").strip()
  145. if diameter:
  146. return diameter
  147. return DEFAULT_NOZZLE_DIAMETER
  148. def resolve_slot_nozzle(state, ams_id: int, tray_id: int, model: str | None = None) -> SlotNozzle:
  149. """The extruder an AMS slot feeds and that nozzle's diameter.
  150. ``state`` is the live ``PrinterState`` (or None when the printer is not
  151. connected, which yields the defaults rather than an error).
  152. """
  153. if state is None:
  154. return SlotNozzle(extruder=None, diameter=DEFAULT_NOZZLE_DIAMETER)
  155. extruder = slot_extruder(
  156. ams_id,
  157. tray_id,
  158. getattr(state, "ams_extruder_map", None),
  159. getattr(state, "ams_switch_inlet", None),
  160. )
  161. return SlotNozzle(
  162. extruder=extruder,
  163. diameter=nozzle_diameter_for_extruder(state, extruder, model),
  164. flow=nozzle_flow_for_extruder(state, extruder, model),
  165. )