| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402 |
- """Profile defaults per product: what a new spool starts with (#1326).
- A default (``models.filament_profile_default``) holds the slicer preset, the
- per-model preset overrides and the K profiles for one product, for all its
- colours (``color_name`` "") or for one. ``resolve_profile_default`` merges the
- two levels, the colour row winning per preset and per hotend, and the
- ``fill_*`` helpers copy the result onto a spool -- filling only what the spool
- does not have yet, so they are safe on a create path that was handed some
- profiles already and on spools that have been in stock for months.
- """
- from __future__ import annotations
- import logging
- from dataclasses import dataclass, field
- from sqlalchemy import delete, inspect as sa_inspect, select
- from sqlalchemy.ext.asyncio import AsyncSession
- from sqlalchemy.orm import selectinload
- from backend.app.models.filament_profile_default import (
- FilamentProfileDefault,
- FilamentProfileDefaultKProfile,
- FilamentProfileDefaultPreset,
- )
- from backend.app.models.spool import Spool
- from backend.app.models.spool_filament_preset import SpoolFilamentPreset, SpoolmanFilamentPreset
- from backend.app.models.spool_k_profile import SpoolKProfile
- from backend.app.models.spoolman_k_profile import SpoolmanKProfile
- logger = logging.getLogger(__name__)
- ProductKey = tuple[str, str, str, str] # (brand, material, subtype, color_name)
- K_FIELDS = ("printer_id", "extruder", "nozzle_diameter", "nozzle_type", "k_value", "name", "cali_idx", "setting_id")
- PRESET_FIELDS = ("printer_model", "nozzle_diameter", "slicer_filament", "slicer_filament_name")
- def product_key(*, brand: str | None, material: str | None, subtype: str | None, color_name: str | None) -> ProductKey:
- """The key a spool's product fields map to: trimmed, "" for missing."""
- return ((brand or "").strip(), (material or "").strip(), (subtype or "").strip(), (color_name or "").strip())
- def match_key(key: ProductKey) -> str:
- """What ``FilamentProfileDefault.match_key`` stores for a product key, and
- what every comparison of product fields here goes through."""
- return "\x1f".join(part.lower() for part in key)
- def hotend_key(printer_id: int, extruder: int | None, nozzle_diameter: str | None) -> tuple[int, int, str]:
- """One hotend: what a spool holds at most one K profile for. The diameter
- falls back to "0.4" as the K-profile columns do."""
- return (printer_id, int(extruder or 0), (nozzle_diameter or "").strip() or "0.4")
- def k_key(row: dict) -> tuple[int, int, str]:
- return hotend_key(row["printer_id"], row.get("extruder"), row.get("nozzle_diameter"))
- def preset_key(row: dict) -> tuple[str, str]:
- return (row.get("printer_model") or "", row.get("nozzle_diameter") or "")
- # Spool.slicer_filament / _name are narrower than the default's columns, which
- # follow the Spoolman schema. A preset id is never cut short: one that does not
- # fit is not filled in at all.
- _SPOOL_SLICER_FILAMENT_LEN = 50
- _SPOOL_SLICER_FILAMENT_NAME_LEN = 100
- @dataclass
- class ResolvedProfiles:
- """A product's default, both levels merged."""
- slicer_filament: str | None = None
- slicer_filament_name: str | None = None
- presets: list[dict] = field(default_factory=list)
- k_profiles: list[dict] = field(default_factory=list)
- # The rows it came from, all-colours first.
- default_ids: list[int] = field(default_factory=list)
- @property
- def is_empty(self) -> bool:
- return not (self.slicer_filament or self.presets or self.k_profiles)
- def _with_children():
- return (
- selectinload(FilamentProfileDefault.presets),
- selectinload(FilamentProfileDefault.k_profiles),
- )
- async def find_default(db: AsyncSession, key: ProductKey) -> FilamentProfileDefault | None:
- """The default stored for exactly this key, case-insensitively."""
- result = await db.execute(
- select(FilamentProfileDefault)
- .options(*_with_children())
- .where(FilamentProfileDefault.match_key == match_key(key))
- )
- return result.scalars().first()
- async def resolve_profile_default(
- db: AsyncSession,
- *,
- brand: str | None,
- material: str | None,
- subtype: str | None,
- color_name: str | None,
- ) -> ResolvedProfiles | None:
- """The profiles a new spool of this product gets, or None when there is no default.
- The all-colours row is the base; the row for the spool's colour replaces
- its slicer preset when it has one, and each of its presets and K profiles
- replaces the base's for the same (model, nozzle) or hotend.
- """
- key = product_key(brand=brand, material=material, subtype=subtype, color_name=color_name)
- if not key[1]:
- return None
- rows = [await find_default(db, (*key[:3], ""))]
- if key[3]:
- rows.append(await find_default(db, key))
- rows = [r for r in rows if r is not None]
- if not rows:
- return None
- resolved = ResolvedProfiles()
- presets: dict[tuple[str, str], dict] = {}
- k_profiles: dict[tuple[int, int, str], dict] = {}
- for row in rows:
- resolved.default_ids.append(row.id)
- if row.slicer_filament:
- resolved.slicer_filament = row.slicer_filament
- resolved.slicer_filament_name = row.slicer_filament_name
- for p in row.presets:
- preset = {name: getattr(p, name) for name in PRESET_FIELDS}
- presets[preset_key(preset)] = preset
- for k in row.k_profiles:
- k_profile = {name: getattr(k, name) for name in K_FIELDS}
- k_profiles[k_key(k_profile)] = k_profile
- resolved.presets = list(presets.values())
- resolved.k_profiles = list(k_profiles.values())
- return resolved
- async def save_default(
- db: AsyncSession,
- key: ProductKey,
- *,
- slicer_filament: str | None,
- slicer_filament_name: str | None,
- presets: list[dict],
- k_profiles: list[dict],
- keep_printer_ids: set[int] | None = None,
- ) -> FilamentProfileDefault:
- """Create or replace the default for ``key``. The caller commits.
- ``keep_printer_ids``: K profiles of these printers are left as they are
- instead of being replaced -- the printers a caller limited to some of them
- (#1727) cannot see, and so cannot have meant to clear.
- """
- row = await find_default(db, key)
- if row is None:
- brand, material, subtype, color_name = key
- row = FilamentProfileDefault(
- match_key=match_key(key),
- brand=brand,
- material=material,
- subtype=subtype,
- color_name=color_name,
- presets=[],
- k_profiles=[],
- )
- db.add(row)
- await db.flush()
- row.slicer_filament = slicer_filament or None
- row.slicer_filament_name = (slicer_filament_name or None) if slicer_filament else None
- await db.execute(
- delete(FilamentProfileDefaultPreset).where(FilamentProfileDefaultPreset.profile_default_id == row.id)
- )
- k_delete = delete(FilamentProfileDefaultKProfile).where(FilamentProfileDefaultKProfile.profile_default_id == row.id)
- if keep_printer_ids:
- k_delete = k_delete.where(FilamentProfileDefaultKProfile.printer_id.not_in(keep_printer_ids))
- await db.execute(k_delete)
- # The deletes before the inserts, or the UNIQUE constraints see both.
- await db.flush()
- kept = {
- hotend_key(k.printer_id, k.extruder, k.nozzle_diameter)
- for k in row.k_profiles
- if keep_printer_ids and k.printer_id in keep_printer_ids
- }
- for p in presets:
- db.add(FilamentProfileDefaultPreset(profile_default_id=row.id, **{n: p.get(n) for n in PRESET_FIELDS}))
- for k in k_profiles:
- if k_key(k) in kept:
- continue
- db.add(FilamentProfileDefaultKProfile(profile_default_id=row.id, **{n: k.get(n) for n in K_FIELDS}))
- await db.flush()
- db.expire(row, ["presets", "k_profiles", "updated_at"])
- return row
- async def load_default(db: AsyncSession, default_id: int) -> FilamentProfileDefault | None:
- result = await db.execute(
- select(FilamentProfileDefault)
- .options(*_with_children())
- .where(FilamentProfileDefault.id == default_id)
- .execution_options(populate_existing=True)
- )
- return result.scalars().first()
- async def fill_internal_spool(
- db: AsyncSession,
- spool: Spool,
- *,
- include_slicer: bool = True,
- resolved: ResolvedProfiles | None = None,
- ) -> bool:
- """Copy the product's default onto an internal spool where it has nothing.
- Never replaces: a slicer preset, a preset for a (model, nozzle) or a K
- profile for a hotend the spool already has stays. ``include_slicer=False``
- leaves the spool's own preset alone even when empty. The caller flushes
- or commits. Returns whether anything was added.
- """
- if resolved is None:
- resolved = await resolve_profile_default(
- db, brand=spool.brand, material=spool.material, subtype=spool.subtype, color_name=spool.color_name
- )
- if resolved is None or resolved.is_empty:
- return False
- changed = False
- if (
- include_slicer
- and resolved.slicer_filament
- and not spool.slicer_filament
- and len(resolved.slicer_filament) <= _SPOOL_SLICER_FILAMENT_LEN
- ):
- spool.slicer_filament = resolved.slicer_filament
- spool.slicer_filament_name = (resolved.slicer_filament_name or "")[:_SPOOL_SLICER_FILAMENT_NAME_LEN] or None
- changed = True
- have_presets: set[tuple[str, str]] = set(
- (
- await db.execute(
- select(SpoolFilamentPreset.printer_model, SpoolFilamentPreset.nozzle_diameter).where(
- SpoolFilamentPreset.spool_id == spool.id
- )
- )
- ).all()
- )
- for p in resolved.presets:
- if preset_key(p) in have_presets:
- continue
- db.add(SpoolFilamentPreset(spool_id=spool.id, **p))
- changed = True
- # The RFID assign that follows an auto-create reads the spool's in-memory
- # list, which create_spool_from_tray initialised: rows only added to the
- # session would miss that assign's K profile.
- loaded = "k_profiles" not in sa_inspect(spool).unloaded
- if loaded:
- have_k = {hotend_key(k.printer_id, k.extruder, k.nozzle_diameter) for k in spool.k_profiles}
- else:
- have_k = {
- hotend_key(pid, ext, dia)
- for pid, ext, dia in (
- await db.execute(
- select(SpoolKProfile.printer_id, SpoolKProfile.extruder, SpoolKProfile.nozzle_diameter).where(
- SpoolKProfile.spool_id == spool.id
- )
- )
- ).all()
- }
- for k in resolved.k_profiles:
- if k_key(k) in have_k:
- continue
- row = SpoolKProfile(spool_id=spool.id, **k)
- if loaded:
- spool.k_profiles.append(row)
- else:
- db.add(row)
- have_k.add(k_key(k))
- changed = True
- return changed
- async def fill_spoolman_rows(db: AsyncSession, spoolman_spool_id: int, resolved: ResolvedProfiles) -> bool:
- """The Bambuddy-side half of ``fill_internal_spool`` for a Spoolman spool:
- preset overrides and K profiles, which live here keyed by the remote id.
- The spool's own slicer preset lives in Spoolman, see the callers. The
- caller commits."""
- changed = False
- have_presets = set(
- (
- await db.execute(
- select(SpoolmanFilamentPreset.printer_model, SpoolmanFilamentPreset.nozzle_diameter).where(
- SpoolmanFilamentPreset.spoolman_spool_id == spoolman_spool_id
- )
- )
- ).all()
- )
- for p in resolved.presets:
- if preset_key(p) in have_presets:
- continue
- db.add(SpoolmanFilamentPreset(spoolman_spool_id=spoolman_spool_id, **p))
- changed = True
- have_k = {
- hotend_key(pid, ext, dia)
- for pid, ext, dia in (
- await db.execute(
- select(SpoolmanKProfile.printer_id, SpoolmanKProfile.extruder, SpoolmanKProfile.nozzle_diameter).where(
- SpoolmanKProfile.spoolman_spool_id == spoolman_spool_id
- )
- )
- ).all()
- }
- for k in resolved.k_profiles:
- if k_key(k) in have_k:
- continue
- db.add(SpoolmanKProfile(spoolman_spool_id=spoolman_spool_id, **k))
- have_k.add(k_key(k))
- changed = True
- return changed
- async def fill_auto_added_spoolman_spool(spool: dict, client=None, *, include_slicer: bool = False) -> None:
- """The profile default for a spool the AMS sync just added to Spoolman.
- Its preset overrides and K profiles, and -- with ``include_slicer``, for a
- tray whose tag names no preset -- the default's own preset, written to the
- spool's extra dict through ``client``. A tag's preset is left alone, as for
- an internal RFID spool. In a session of its own because the sync's callers
- do not commit, and a failure is only logged: the spool is in Spoolman
- either way.
- """
- import json
- from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
- from backend.app.core.database import async_session
- from backend.app.services.spool_filament_preset import forget_spoolman_spool_own_preset
- try:
- mapped = _map_spoolman_spool(spool)
- async with async_session() as db:
- resolved = await resolve_profile_default(
- db,
- brand=mapped["brand"],
- material=mapped["material"],
- subtype=mapped["subtype"],
- color_name=None if mapped["color_name_is_synthesized"] else mapped["color_name"],
- )
- if resolved is None:
- return
- if await fill_spoolman_rows(db, mapped["id"], resolved):
- await db.commit()
- if include_slicer and client is not None and resolved.slicer_filament and not mapped["slicer_filament"]:
- extra = {"bambu_slicer_filament": json.dumps(resolved.slicer_filament)}
- if resolved.slicer_filament_name:
- extra["bambu_slicer_filament_name"] = json.dumps(resolved.slicer_filament_name)
- for name in extra:
- await client.ensure_extra_field(name)
- await client.merge_spool_extra(mapped["id"], extra)
- forget_spoolman_spool_own_preset(mapped["id"])
- except Exception as exc: # noqa: BLE001 — never fail the AMS sync over a default
- logger.warning("Profile default for auto-added Spoolman spool %s not applied: %s", spool.get("id"), exc)
- def default_matches(row: FilamentProfileDefault, *, brand, material, subtype, color_name) -> bool:
- """Whether a spool with these fields is covered by ``row`` (its colour, or
- any colour for an all-colours row)."""
- spool = match_key(product_key(brand=brand, material=material, subtype=subtype, color_name=color_name)).split("\x1f")
- want = row.match_key.split("\x1f")
- return spool[:3] == want[:3] and (not want[3] or spool[3] == want[3])
- async def apply_to_internal_spools(db: AsyncSession, row: FilamentProfileDefault) -> int:
- """Fill every active spool ``row`` covers. Returns how many changed; the
- caller commits."""
- # Matched in Python, with the same fold as the defaults' match_key.
- candidates = (await db.execute(select(Spool).where(Spool.archived_at.is_(None)))).scalars().all()
- spools = [
- s
- for s in candidates
- if default_matches(row, brand=s.brand, material=s.material, subtype=s.subtype, color_name=s.color_name)
- ]
- changed = 0
- cache: dict[str, ResolvedProfiles | None] = {}
- for spool in spools:
- # Each spool resolves on its own colour: applying the all-colours row
- # to a colour that has a row of its own still gives that colour its values.
- colour = (spool.color_name or "").strip().lower()
- if colour not in cache:
- cache[colour] = await resolve_profile_default(
- db, brand=row.brand, material=row.material, subtype=row.subtype, color_name=spool.color_name
- )
- if await fill_internal_spool(db, spool, resolved=cache[colour]):
- changed += 1
- return changed
|