| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158 |
- """Record on each spool when it was last dried (#2863).
- An AMS drying cycle dries every spool in that unit, but the AMS humidity only
- describes the unit, so once a spool is moved the fact is lost. When a cycle
- ends, every spool assigned to a slot of that AMS is stamped with the time, the
- target temperature and the hours it actually ran, in whichever inventory holds
- the slot assignments.
- A cycle counts when it ran at least ``DRIED_MIN_FRACTION`` of its length. One
- stopped a few minutes in, by the user, the firmware or a print taking
- priority, has not dried anything and must not overwrite an earlier, real
- drying.
- """
- from __future__ import annotations
- import json
- import logging
- from dataclasses import dataclass
- from sqlalchemy import select
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.api.routes._spoolman_helpers import (
- BAMBU_LAST_DRIED_AT_KEY,
- BAMBU_LAST_DRIED_HOURS_KEY,
- BAMBU_LAST_DRIED_TEMP_KEY,
- )
- from backend.app.services.bambu_mqtt import DryingCycleEnd
- from backend.app.utils.local_time import utcnow_naive
- logger = logging.getLogger(__name__)
- # Share of a cycle's length that must have run for its spools to count as dried.
- DRIED_MIN_FRACTION = 0.5
- # A cycle that runs to term ends with its countdown all but exhausted; the last
- # dry_time seen can trail by a push or two. Same margin the MQTT client uses to
- # tell a finished cycle from an aborted one.
- RAN_TO_TERM_MINUTES = 5
- @dataclass(frozen=True)
- class DryingRecord:
- temp: int | None
- hours: float | None
- def drying_record(cycle: DryingCycleEnd) -> DryingRecord | None:
- """What a finished cycle says about its spools, or None when it does not count.
- The cycle's length is the requested duration when Bambuddy started it, else
- the highest countdown seen. When the start was not watched and nothing was
- requested, that peak understates the length, so the elapsed share is a
- lower bound and the hours are left unknown rather than understated. A cycle
- that ran to term counts either way.
- """
- requested = (cycle.target_hours or 0) * 60
- length = max(cycle.peak_minutes, requested)
- elapsed = max(0, length - cycle.remaining_minutes)
- ran_to_term = cycle.remaining_minutes <= RAN_TO_TERM_MINUTES
- if not ran_to_term and (length <= 0 or elapsed / length < DRIED_MIN_FRACTION):
- return None
- length_known = cycle.start_seen or requested > 0
- hours = round(elapsed / 60, 1) if length_known and elapsed > 0 else None
- return DryingRecord(temp=cycle.target_temp, hours=hours)
- async def _spoolman_spool_ids(db: AsyncSession, printer_id: int, ams_id: int) -> list[int]:
- from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
- result = await db.execute(
- select(SpoolmanSlotAssignment.spoolman_spool_id).where(
- SpoolmanSlotAssignment.printer_id == printer_id,
- SpoolmanSlotAssignment.ams_id == ams_id,
- )
- )
- return sorted(set(result.scalars().all()))
- async def _stamp_internal(db: AsyncSession, printer_id: int, ams_id: int, record: DryingRecord) -> int:
- from backend.app.models.spool import Spool
- from backend.app.models.spool_assignment import SpoolAssignment
- result = await db.execute(
- select(Spool)
- .join(SpoolAssignment, SpoolAssignment.spool_id == Spool.id)
- .where(SpoolAssignment.printer_id == printer_id, SpoolAssignment.ams_id == ams_id)
- )
- spools = list(result.scalars().unique().all())
- now = utcnow_naive()
- for spool in spools:
- spool.last_dried_at = now
- spool.last_dried_temp = record.temp
- spool.last_dried_hours = record.hours
- if spools:
- await db.commit()
- return len(spools)
- async def _stamp_spoolman(spool_ids: list[int], record: DryingRecord) -> int:
- from backend.app.services.spoolman import get_spoolman_client
- client = await get_spoolman_client()
- if client is None:
- logger.info("Drying not recorded on Spoolman spools %s: Spoolman is not connected", spool_ids)
- return 0
- # JSON strings: the form Spoolman's default "text" extra fields accept.
- fields = {
- BAMBU_LAST_DRIED_AT_KEY: json.dumps(utcnow_naive().isoformat(timespec="seconds")),
- # An empty string clears a value left by an earlier cycle.
- BAMBU_LAST_DRIED_TEMP_KEY: json.dumps("" if record.temp is None else str(record.temp)),
- BAMBU_LAST_DRIED_HOURS_KEY: json.dumps("" if record.hours is None else str(record.hours)),
- }
- stamped = 0
- for spool_id in spool_ids:
- try:
- await client.merge_spool_extra(spool_id, fields)
- stamped += 1
- except Exception as exc: # noqa: BLE001 — one unreachable spool must not skip the rest
- logger.warning("Could not record drying on Spoolman spool %d: %s", spool_id, exc)
- return stamped
- async def record_drying_cycle(db: AsyncSession, printer_id: int, cycle: DryingCycleEnd) -> int:
- """Stamp the spools of the AMS that just finished drying. Returns how many."""
- from backend.app.services.inventory_mode import spoolman_owns_assignments
- record = drying_record(cycle)
- if record is None:
- logger.info(
- "Printer %d AMS %d drying ended with %d of %d minutes left: too short to mark its spools as dried",
- printer_id,
- cycle.ams_id,
- cycle.remaining_minutes,
- max(cycle.peak_minutes, (cycle.target_hours or 0) * 60),
- )
- return 0
- if await spoolman_owns_assignments(db):
- spool_ids = await _spoolman_spool_ids(db, printer_id, cycle.ams_id)
- stamped = await _stamp_spoolman(spool_ids, record) if spool_ids else 0
- else:
- stamped = await _stamp_internal(db, printer_id, cycle.ams_id, record)
- logger.info(
- "Printer %d AMS %d drying finished (%s °C, %s h): marked %d spool(s) as dried",
- printer_id,
- cycle.ams_id,
- record.temp if record.temp is not None else "?",
- record.hours if record.hours is not None else "?",
- stamped,
- )
- if stamped:
- from backend.app.core.websocket import ws_manager
- await ws_manager.broadcast({"type": "inventory_changed"})
- return stamped
|