spool_drying.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. """Record on each spool when it was last dried (#2863).
  2. An AMS drying cycle dries every spool in that unit, but the AMS humidity only
  3. describes the unit, so once a spool is moved the fact is lost. When a cycle
  4. ends, every spool assigned to a slot of that AMS is stamped with the time, the
  5. target temperature and the hours it actually ran, in whichever inventory holds
  6. the slot assignments.
  7. A cycle counts when it ran at least ``DRIED_MIN_FRACTION`` of its length. One
  8. stopped a few minutes in, by the user, the firmware or a print taking
  9. priority, has not dried anything and must not overwrite an earlier, real
  10. drying.
  11. """
  12. from __future__ import annotations
  13. import json
  14. import logging
  15. from dataclasses import dataclass
  16. from sqlalchemy import select
  17. from sqlalchemy.ext.asyncio import AsyncSession
  18. from backend.app.api.routes._spoolman_helpers import (
  19. BAMBU_LAST_DRIED_AT_KEY,
  20. BAMBU_LAST_DRIED_HOURS_KEY,
  21. BAMBU_LAST_DRIED_TEMP_KEY,
  22. )
  23. from backend.app.services.bambu_mqtt import DryingCycleEnd
  24. from backend.app.utils.local_time import utcnow_naive
  25. logger = logging.getLogger(__name__)
  26. # Share of a cycle's length that must have run for its spools to count as dried.
  27. DRIED_MIN_FRACTION = 0.5
  28. # A cycle that runs to term ends with its countdown all but exhausted; the last
  29. # dry_time seen can trail by a push or two. Same margin the MQTT client uses to
  30. # tell a finished cycle from an aborted one.
  31. RAN_TO_TERM_MINUTES = 5
  32. @dataclass(frozen=True)
  33. class DryingRecord:
  34. temp: int | None
  35. hours: float | None
  36. def drying_record(cycle: DryingCycleEnd) -> DryingRecord | None:
  37. """What a finished cycle says about its spools, or None when it does not count.
  38. The cycle's length is the requested duration when Bambuddy started it, else
  39. the highest countdown seen. When the start was not watched and nothing was
  40. requested, that peak understates the length, so the elapsed share is a
  41. lower bound and the hours are left unknown rather than understated. A cycle
  42. that ran to term counts either way.
  43. """
  44. requested = (cycle.target_hours or 0) * 60
  45. length = max(cycle.peak_minutes, requested)
  46. elapsed = max(0, length - cycle.remaining_minutes)
  47. ran_to_term = cycle.remaining_minutes <= RAN_TO_TERM_MINUTES
  48. if not ran_to_term and (length <= 0 or elapsed / length < DRIED_MIN_FRACTION):
  49. return None
  50. length_known = cycle.start_seen or requested > 0
  51. hours = round(elapsed / 60, 1) if length_known and elapsed > 0 else None
  52. return DryingRecord(temp=cycle.target_temp, hours=hours)
  53. async def _spoolman_spool_ids(db: AsyncSession, printer_id: int, ams_id: int) -> list[int]:
  54. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  55. result = await db.execute(
  56. select(SpoolmanSlotAssignment.spoolman_spool_id).where(
  57. SpoolmanSlotAssignment.printer_id == printer_id,
  58. SpoolmanSlotAssignment.ams_id == ams_id,
  59. )
  60. )
  61. return sorted(set(result.scalars().all()))
  62. async def _stamp_internal(db: AsyncSession, printer_id: int, ams_id: int, record: DryingRecord) -> int:
  63. from backend.app.models.spool import Spool
  64. from backend.app.models.spool_assignment import SpoolAssignment
  65. result = await db.execute(
  66. select(Spool)
  67. .join(SpoolAssignment, SpoolAssignment.spool_id == Spool.id)
  68. .where(SpoolAssignment.printer_id == printer_id, SpoolAssignment.ams_id == ams_id)
  69. )
  70. spools = list(result.scalars().unique().all())
  71. now = utcnow_naive()
  72. for spool in spools:
  73. spool.last_dried_at = now
  74. spool.last_dried_temp = record.temp
  75. spool.last_dried_hours = record.hours
  76. if spools:
  77. await db.commit()
  78. return len(spools)
  79. async def _stamp_spoolman(spool_ids: list[int], record: DryingRecord) -> int:
  80. from backend.app.services.spoolman import get_spoolman_client
  81. client = await get_spoolman_client()
  82. if client is None:
  83. logger.info("Drying not recorded on Spoolman spools %s: Spoolman is not connected", spool_ids)
  84. return 0
  85. # JSON strings: the form Spoolman's default "text" extra fields accept.
  86. fields = {
  87. BAMBU_LAST_DRIED_AT_KEY: json.dumps(utcnow_naive().isoformat(timespec="seconds")),
  88. # An empty string clears a value left by an earlier cycle.
  89. BAMBU_LAST_DRIED_TEMP_KEY: json.dumps("" if record.temp is None else str(record.temp)),
  90. BAMBU_LAST_DRIED_HOURS_KEY: json.dumps("" if record.hours is None else str(record.hours)),
  91. }
  92. stamped = 0
  93. for spool_id in spool_ids:
  94. try:
  95. await client.merge_spool_extra(spool_id, fields)
  96. stamped += 1
  97. except Exception as exc: # noqa: BLE001 — one unreachable spool must not skip the rest
  98. logger.warning("Could not record drying on Spoolman spool %d: %s", spool_id, exc)
  99. return stamped
  100. async def record_drying_cycle(db: AsyncSession, printer_id: int, cycle: DryingCycleEnd) -> int:
  101. """Stamp the spools of the AMS that just finished drying. Returns how many."""
  102. from backend.app.services.inventory_mode import spoolman_owns_assignments
  103. record = drying_record(cycle)
  104. if record is None:
  105. logger.info(
  106. "Printer %d AMS %d drying ended with %d of %d minutes left: too short to mark its spools as dried",
  107. printer_id,
  108. cycle.ams_id,
  109. cycle.remaining_minutes,
  110. max(cycle.peak_minutes, (cycle.target_hours or 0) * 60),
  111. )
  112. return 0
  113. if await spoolman_owns_assignments(db):
  114. spool_ids = await _spoolman_spool_ids(db, printer_id, cycle.ams_id)
  115. stamped = await _stamp_spoolman(spool_ids, record) if spool_ids else 0
  116. else:
  117. stamped = await _stamp_internal(db, printer_id, cycle.ams_id, record)
  118. logger.info(
  119. "Printer %d AMS %d drying finished (%s °C, %s h): marked %d spool(s) as dried",
  120. printer_id,
  121. cycle.ams_id,
  122. record.temp if record.temp is not None else "?",
  123. record.hours if record.hours is not None else "?",
  124. stamped,
  125. )
  126. if stamped:
  127. from backend.app.core.websocket import ws_manager
  128. await ws_manager.broadcast({"type": "inventory_changed"})
  129. return stamped