slot_nozzle.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  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 nozzle_flow_for_extruder(state, extruder: int | None, model: str | None = None) -> str | None:
  86. """The flow type fitted to ``extruder``, or None when the printer is silent.
  87. Read from the same array as the diameter and indexed the same way. A
  88. printer that reports no nozzle type -- an X1C sends none at all -- yields
  89. None, which ``flow_matches`` treats as "applies to anything" rather than
  90. inventing Standard.
  91. """
  92. nozzles = getattr(state, "nozzles", None) or []
  93. if not nozzles:
  94. return None
  95. index = 0
  96. if extruder is not None and extruder > 0 and is_dual_nozzle_model(model):
  97. index = extruder
  98. for candidate in (index, 0):
  99. if candidate < len(nozzles):
  100. flow = normalise_flow(getattr(nozzles[candidate], "nozzle_type", ""))
  101. if flow:
  102. return flow
  103. return None
  104. def nozzle_diameter_for_extruder(state, extruder: int | None, model: str | None = None) -> str:
  105. """The diameter fitted to ``extruder``, or the printer's only nozzle.
  106. Falls back to index 0, and then to 0.4, whenever the printer has not
  107. reported the entry -- an absent nozzle must not make this raise, since it is
  108. called on every assign.
  109. """
  110. nozzles = getattr(state, "nozzles", None) or []
  111. if not nozzles:
  112. return DEFAULT_NOZZLE_DIAMETER
  113. index = 0
  114. if extruder is not None and extruder > 0 and is_dual_nozzle_model(model):
  115. # Physical nozzle id N sits on extruder N -- see the module docstring
  116. # for why the legacy left/right convention cannot apply here.
  117. index = extruder
  118. for candidate in (index, 0):
  119. if candidate < len(nozzles):
  120. diameter = (getattr(nozzles[candidate], "nozzle_diameter", "") or "").strip()
  121. if diameter:
  122. return diameter
  123. return DEFAULT_NOZZLE_DIAMETER
  124. def resolve_slot_nozzle(state, ams_id: int, tray_id: int, model: str | None = None) -> SlotNozzle:
  125. """The extruder an AMS slot feeds and that nozzle's diameter.
  126. ``state`` is the live ``PrinterState`` (or None when the printer is not
  127. connected, which yields the defaults rather than an error).
  128. """
  129. if state is None:
  130. return SlotNozzle(extruder=None, diameter=DEFAULT_NOZZLE_DIAMETER)
  131. extruder = slot_extruder(
  132. ams_id,
  133. tray_id,
  134. getattr(state, "ams_extruder_map", None),
  135. getattr(state, "ams_switch_inlet", None),
  136. )
  137. return SlotNozzle(
  138. extruder=extruder,
  139. diameter=nozzle_diameter_for_extruder(state, extruder, model),
  140. flow=nozzle_flow_for_extruder(state, extruder, model),
  141. )