spool_filament_preset.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. """Resolve which slicer filament preset a spool should use on a given nozzle.
  2. ``Spool.slicer_filament`` is the spool's single, printer-agnostic answer. It is
  3. right until the same spool is used on two printer models, because a cloud or
  4. Orca preset is bound to a model (``@BBL X1C``): assigning that spool to an H2C
  5. writes a slot preset the H2C has no profile for. ``SpoolFilamentPreset`` stores
  6. the per-model exceptions and this module is the only thing that reads them, so
  7. the internal-inventory and Spoolman-inventory assign paths cannot drift apart
  8. the way they did before #1713.
  9. Resolution order, most specific first:
  10. 1. (printer_model, nozzle_diameter) -- what the spool form writes, one
  11. row per nozzle size
  12. 2. (printer_model, "") -- a whole-model value; the form does
  13. not write these, but the API accepts
  14. them and they still resolve
  15. 3. ``Spool.slicer_filament`` -- what the spool carries today
  16. Every step is a plain equality match on stored strings; nothing is inferred
  17. from preset names. A model with no row at all resolves to step 3, which is
  18. exactly the behaviour every install has now, so a spool nobody has configured
  19. per-model behaves identically before and after this feature.
  20. """
  21. from __future__ import annotations
  22. import logging
  23. from sqlalchemy import select
  24. from sqlalchemy.ext.asyncio import AsyncSession
  25. from backend.app.models.spool_filament_preset import SpoolFilamentPreset, SpoolmanFilamentPreset
  26. logger = logging.getLogger(__name__)
  27. # What ``resolve_*`` returns: (slicer_filament, slicer_filament_name).
  28. PresetPair = tuple[str | None, str | None]
  29. def _pick(
  30. rows: list[SpoolFilamentPreset] | list[SpoolmanFilamentPreset],
  31. printer_model: str | None,
  32. nozzle_diameter: str | None,
  33. fallback: PresetPair,
  34. ) -> PresetPair:
  35. """Apply the cascade to rows already fetched for one spool.
  36. Split out so both spool flavours share it, and so callers that already
  37. hold the rows (the spool form's read path) do not re-query.
  38. """
  39. model = (printer_model or "").strip()
  40. if not model:
  41. # No model means no way to be more specific than the spool's own value.
  42. # This is the normal answer for a printer that has not reported yet.
  43. return fallback
  44. diameter = (nozzle_diameter or "").strip()
  45. exact: PresetPair | None = None
  46. model_default: PresetPair | None = None
  47. for row in rows:
  48. if row.printer_model != model:
  49. continue
  50. if diameter and row.nozzle_diameter == diameter:
  51. exact = (row.slicer_filament, row.slicer_filament_name)
  52. elif row.nozzle_diameter == "":
  53. model_default = (row.slicer_filament, row.slicer_filament_name)
  54. chosen = exact or model_default
  55. if chosen is None:
  56. return fallback
  57. # A row that exists but carries no preset id is a deliberate "use nothing
  58. # here", not a hole to fall through: the user picked the blank entry for
  59. # this model. Falling back would silently reinstate the value they cleared.
  60. return chosen
  61. def printer_safe_filament_id(*candidates: str | None) -> str:
  62. """First candidate the printer will accept as a filament id, or "".
  63. ``extrusion_cali_sel`` carries a filament id so the printer can link the
  64. calibration index to the slot's filament. A cloud USER preset id
  65. (``PFUS``/``PFCN`` prefix) is not one the slicer accepts -- the assign paths
  66. have refused those for tray_info_idx since #1713, and the same holds here.
  67. This matters now that a per-model override can BE such an id: a user picking
  68. their own cloud preset for a model stores its ``PFUS...`` id, and passing
  69. that straight through would send the printer a value it rejects, silently
  70. losing the K-profile link. Falls through to the next candidate instead --
  71. normally the spool's own preset, then the tray's RFID value.
  72. """
  73. for candidate in candidates:
  74. value = (candidate or "").strip()
  75. if value and not value.startswith(("PFUS", "PFCN")):
  76. return value
  77. return ""
  78. async def resolve_spool_preset(
  79. db: AsyncSession,
  80. *,
  81. spool_id: int,
  82. printer_model: str | None,
  83. nozzle_diameter: str | None,
  84. fallback_filament: str | None,
  85. fallback_name: str | None,
  86. ) -> PresetPair:
  87. """Cascade for an internal-inventory spool. See the module docstring."""
  88. result = await db.execute(select(SpoolFilamentPreset).where(SpoolFilamentPreset.spool_id == spool_id))
  89. return _pick(list(result.scalars().all()), printer_model, nozzle_diameter, (fallback_filament, fallback_name))
  90. async def resolve_spoolman_preset(
  91. db: AsyncSession,
  92. *,
  93. spoolman_spool_id: int,
  94. printer_model: str | None,
  95. nozzle_diameter: str | None,
  96. fallback_filament: str | None,
  97. fallback_name: str | None,
  98. ) -> PresetPair:
  99. """Cascade for a Spoolman-managed spool. See the module docstring."""
  100. result = await db.execute(
  101. select(SpoolmanFilamentPreset).where(SpoolmanFilamentPreset.spoolman_spool_id == spoolman_spool_id)
  102. )
  103. return _pick(list(result.scalars().all()), printer_model, nozzle_diameter, (fallback_filament, fallback_name))