diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a9662c35..8dde6a098 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [1.2.6b1] - Unreleased ### Added +- **A spool can carry a different filament preset on each printer model, and its K profiles are picked per hotend** — A slicer preset is bound to a printer model: `Bambu PLA Basic @BBL X1C` is not the same preset as `@BBL H2C`. A spool stored exactly one, which was right until the same spool was used on a second model — the AMS slot on the other machine was then configured with a preset that machine has no profile for. The spool form's PA Profile tab is now a **Printers** tab holding both halves of the answer: a model list on the left, and on the right that model's filament presets and the K profiles for each of its hotends. Presets are keyed on the printer *model*, because `@BBL X1C` is the same preset on every X1C you own and asking once per machine would mean picking the identical value twice; K profiles stay keyed on the individual printer, extruder and nozzle diameter, because a K value is measured on one physical hotend and two machines of the same model legitimately differ. Both halves cover **every nozzle size — 0.2, 0.4, 0.6 and 0.8 — not only the size currently fitted**, because a spool is configured once and nozzles get swapped: presets get a row per size (the preset is written to an AMS slot, a slot feeds exactly one nozzle, and Bambu names its presets per size anyway), and K profiles are laid out as a grid with size down the side and hotend across the top, a dash marking a size the printer has no calibration for. Anything left alone inherits the spool's own preset and keeps inheriting it when that changes later, so only what actually differs needs an override, and a spool nobody has configured behaves exactly as it did before. Each model is offered only the presets that name it — using the same matcher the Configure AMS Slot modal filters with, now shared between them — while presets whose name identifies no model, which is most user-authored and OrcaSlicer ones, stay available everywhere, and a preset already saved is never hidden from the control that shows it. **Auto-match** fills every size of every model with the variant of the spool's preset that names it, preferring the variant for that exact size; a model with no such variant is left inherited rather than given an approximate one. The model list stays one row per model however large the fleet is, and carries a count of hotends still without a K profile so an unfinished spool is visible without opening anything. Alongside this, a real defect in the old picker is fixed: it identified a chosen calibration by `cali_idx` alone, and the printer numbers its calibration table **per nozzle** — on a dual-nozzle printer the same index exists on both hotends meaning different things, so saving could persist the other hotend's K value and nozzle diameter. Each hotend is now keyed by (printer, extruder, diameter) throughout, which also lets a 0.4 and a 0.6 profile for the same hotend coexist — something both K tables could always store but the picker could not express. The same `cali_idx` fix is applied to SpoolBuddy's write-tag page, which carried a verbatim copy of the lookup. Every path that configures an AMS slot now respects both: the manual assign in either inventory mode, the RFID auto-assign, the Spoolman tag link, the re-fire when a slot goes from empty to loaded, the re-apply after a calibration-table refresh, and the re-selection when a Filament Track Switch moves an AMS to the other nozzle. Which nozzle a slot feeds, and how wide it is, was worked out independently in seven of those places, each reading `nozzles[0]` for every slot on the machine — correct on a single-nozzle printer and on a dual-nozzle printer with matching nozzles, wrong the moment two sizes are fitted. That resolution now lives in one place, and which array entry belongs to which hotend is no longer inferred: measured on an H2D fitted with a 0.4 on the left and a 0.6 on the right, `nozzles[0]` reads the **right** hotend, so the array is indexed by extruder id. A printer card in expanded view now lists every fitted size rather than the first entry alone, which on a mixed-diameter machine named one hotend and implied it was the whole printer. Three defects surfaced while wiring it. The **RFID auto-assign chose a K profile with no extruder test at all** — the first row matching (printer, nozzle size) won, so a spool calibrated on both hotends of a dual-nozzle printer had a coin toss decide which pressure-advance value the slot got, on the path that runs unattended every time a Bambu spool is loaded. The **Spoolman tag-link path resolved no preset whatsoever**, configuring every linked slot with a generic material id and discarding a preset set in inventory — the same defect #1713 fixed on the assign path, in the function next door. And an **FTS inlet move re-selected the K profile for nozzle 0 rather than for the nozzle the AMS had just been moved to**, which on a mixed-diameter machine is the wrong nozzle by construction. The Configure AMS Slot dialog now opens on the spool's own configured values — its preset for this printer's model and its K profile for this slot's hotend — falling back to the slot's last manual configuration and then the tray's RFID data, rather than ignoring what the spool was configured with on the one screen that looks like it exists for it. Reading a printer's calibration table now asks for one nozzle size at a time rather than all at once: H2-series firmware answers only the first one or two of a concurrent burst and silently drops the rest, each dropped request costing a five-second timeout before its retry — measured at 11 and 23 seconds on an H2C and an H2D against roughly one second in series — while the printers themselves are read in parallel, since separate machines are separate connections. Every preset in the form now carries an origin badge — Bambu Cloud, Orca Cloud, Local or Built-in — in the same wording and colours the Configure AMS Slot modal has used since #1623, because the same filament exists in several of those sources and which one is picked decides what actually reaches the printer. The spool form is wider to fit the two-pane layout, its colour, weight, cost and location fields now live on their own **Color & Cost** tab rather than at the bottom of a long scroll, and those fields are laid out in two columns instead of one field per row. - **An Avery sheet can start at the first unused position, so a part-used sheet is not thrown away (#2879, requested and contributed by @whitigol in #2918)** — Label PDFs always began in the top-left slot, so the second batch printed onto a sheet that already had seven labels taken off it would have printed over the gaps. Spool labels are printed a few at a time as filament arrives, which meant spending a 30-slot Avery 5160 sheet on two labels or nothing. A **Starting label position** field in the print-label dialog now says which slot to begin at, counted the way the sheet reads — left to right, top to bottom, starting at 1. The offset applies to the first page only and later pages restart at slot 1, because the number describes the sheet already in the tray rather than anything about the job: the second sheet the printer pulls is a fresh one. Position 1 is the default and is what a request that omits the field means, so both label endpoints — built-in inventory and Spoolman — behave exactly as they did for anyone who never touches it. The bounds are per template and enforced on both sides, 1–21 for Avery L7160 and 1–30 for Avery 5160; the server derives them from the sheet layout table it already lays labels out from rather than carrying a second copy of the numbers, so the two cannot drift, and a non-default position is refused outright for the single-label roll templates where a sheet slot means nothing. In the dialog the two sheet buttons disagree about the same number — 25 is valid on a 5160 and off the end of an L7160 — so the button whose capacity the value exceeds is disabled and its hint is replaced by that sheet's own range, instead of leaving a disabled button next to guidance that says the value is fine. The sentence naming the skipped slots deliberately avoids i18next's reserved `count` variable: with no `_one` form defined, a value of 1 resolves past the translation to the English default, so every non-English locale would have printed an English sentence at position 2 and only at position 2. Translated in all 13 locales, README and wiki updated, and covered by renderer, endpoint and dialog tests including the page-boundary cases where the selection exactly fills the offset first sheet and where it runs one past it. - **Home Assistant sensors can be bound to a storage location, so a drybox reports its own humidity (#2824, requested and contributed by @MagicMelody84 in #2827)** — A temperature, humidity or battery entity can be attached to any storage location, and its reading shows where the filament in that location is already being looked at: on the spool cards in Inventory, and in three optional Inventory table columns that are off by default. Values can be colourised against the location's own thresholds, so a rack of dryboxes reads at a glance rather than one number at a time. Each binding carries its own alert rule — above/below for a numeric sensor, on/off for a binary one — and can raise a notification when it crosses into that state, on the edge only: a drybox that was already too humid when Bambuddy restarted has not just become too humid, and re-announcing every pre-existing alert on every restart is how people learn to ignore the alert. That notification is its own event with its own provider toggle and template rather than sharing the printer sensors' one, because a provider can be scoped to a single printer and a location has no printer to scope by — sharing the column would have delivered drybox alerts to a channel deliberately narrowed to one machine, with no way to have one without the other. A background poller reads the bound entities on a configurable cadence, defaulting to two minutes and floored at one, and polls before it sleeps so a restart does not leave every card blank for a full interval. Nothing is polled for a location that has no sensor bound, and in table view nothing is polled at all while the three columns are hidden, so the installs that never touch Home Assistant — which is most of them — issue no extra requests. New bindings are seeded from defaults that live on the server, not in the browser, since they become the alert rule written onto each row and two admins binding sensors from different machines must not seed different rules; the colour choices stay per-browser, because those are only how one person likes to read the page. Home Assistant's `moisture` class is deliberately not offered: it is a binary wet/dry leak detector rather than a humidity percentage, and treating it as one put "wet" in a percent-formatted column and let it block the real hygrometer on the same location. One entity can be bound to a location once, enforced by a unique index rather than by the check alone, because that check is read-then-insert and two concurrent creates could both pass it; the upgrade collapses any duplicate that slipped in before the index existed instead of failing to build over it. An API key can read the bindings but not create or edit them, matching the printer bindings exactly. Translated in all 13 locales, with backend and frontend regression tests throughout. - **A fault's description is in the status response, so a client no longer needs its own copy of the table (#2926, proposed and analysed by @sadontsev)** — `HMS_ERROR_DESCRIPTIONS` has been in the backend all along and the status response never carried it, so every consumer that wanted to tell a user *why* a print halted resolved the same 853 codes from its own duplicate of the same sentences — this repo's Python table, the frontend modal's, and at least one third-party iOS client whose catalogue exists purely because the server would not say. Each aged separately, and a push relay watching a printer could only manage "your printer needs attention" while the server already knew it was "Filament ran out. Please load new filament." `hms_errors[]` entries now carry `description`, defaulting to null so a client that has never seen the field is unaffected. It is resolved once, where the fault is parsed, rather than at the boundary that happened to prompt the request: there are three separate serializers of a fault — the status response, the WebSocket broadcast, and the print-completion payload the queue's failure reason is built from — and adding it to only the first would have delivered half the feature to a relay watching the stream, which is the likelier consumer. The queue's failure reason now quotes the same sentence instead of resolving the code a fourth time, and the notification path reads it rather than re-deriving its own. Resolving in one place is also what makes the three unable to drift, which is pinned by a test that asserts they agree. Resolution is exactly what the codebase already did, verified rather than assumed: an 8-char `print_error` is the catalogue's `MMMM_EEEE` key split in half, and a 16-char `hms[]` identifier is tried whole and then collapsed to its first and last groups, which is how the notification path, the queue's failure-reason helper and the frontend modal have always resolved those. The collapse is lossy — #2728 counts 65 documented faults falling onto `0300_0001` alone — and it is kept rather than tightened here because refusing it would not read the same data more strictly, it would stop describing faults that are described today and leave this field null while the UI shows text for the same fault. Narrowing it belongs with #2728, where both key spaces can move together. A fault the catalogue does not cover reports null and is still reported in full; `full_code` identifies it either way. The equivalence is pinned by a test that checks every catalogue code in both fault shapes across all three alert levels, so a future change to the lookup cannot silently stop notifications from firing. The text is English only and unlocalized, which the schema says next to the field. The frontend keeps resolving its own text for now; switching it over would change what `filterKnownHMSErrors` counts across eight call sites, which is #1840 and #2728's argument rather than this one's. diff --git a/backend/app/api/routes/inventory.py b/backend/app/api/routes/inventory.py index b9b0c968c..b9417a987 100644 --- a/backend/app/api/routes/inventory.py +++ b/backend/app/api/routes/inventory.py @@ -26,6 +26,7 @@ from backend.app.models.settings import Settings from backend.app.models.spool import Spool from backend.app.models.spool_assignment import SpoolAssignment from backend.app.models.spool_catalog import SpoolCatalogEntry +from backend.app.models.spool_filament_preset import SpoolFilamentPreset from backend.app.models.spool_k_profile import SpoolKProfile from backend.app.models.user import User from backend.app.schemas.location import LocationCreate, LocationResponse, LocationUpdate @@ -34,6 +35,8 @@ from backend.app.schemas.spool import ( SpoolAssignmentResponse, SpoolBulkCreate, SpoolCreate, + SpoolFilamentPresetBase, + SpoolFilamentPresetResponse, SpoolKProfileBase, SpoolKProfileResponse, SpoolResponse, @@ -53,6 +56,7 @@ from backend.app.services.location_service import ( rename_location as rename_location_record, ) from backend.app.services.slicer_filament_resolver import resolve_slicer_filament +from backend.app.services.slot_nozzle import resolve_slot_nozzle from backend.app.services.spool_csv import ( MAX_CSV_IMPORT_BYTES, ImportPreview, @@ -60,6 +64,7 @@ from backend.app.services.spool_csv import ( parse_and_validate, serialize, ) +from backend.app.services.spool_filament_preset import resolve_spool_preset from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client from backend.app.utils.filament_ids import ( GENERIC_FILAMENT_IDS, @@ -130,6 +135,27 @@ async def apply_spool_to_slot_via_mqtt( _generic_id_values = _GENERIC_ID_VALUES + # Which nozzle this slot feeds, and how wide it is. One resolution shared + # with every other path that configures a slot (see services.slot_nozzle), + # and used twice below -- for the spool's per-model preset override and for + # its K profile -- so the two lookups cannot answer for different nozzles. + slot_nozzle = resolve_slot_nozzle(state, ams_id, tray_id, printer_manager.get_model(printer_id)) + nozzle_diameter = slot_nozzle.diameter + + # A cloud or Orca preset is bound to a printer MODEL ("@BBL X1C"), so the + # spool's single slicer_filament stops being right the moment the same + # spool is used on a second model. resolve_spool_preset returns the + # spool's own value unless the user has set an override for this model, + # so a spool nobody has configured behaves exactly as it did before. + slot_slicer_filament, slot_slicer_filament_name = await resolve_spool_preset( + db, + spool_id=spool.id, + printer_model=printer_manager.get_model(printer_id), + nozzle_diameter=nozzle_diameter, + fallback_filament=spool.slicer_filament, + fallback_name=spool.slicer_filament_name, + ) + # slicer_filament → (tray_info_idx, setting_id) resolution is shared with # the Spoolman-mode route via this helper (#1713). The helper handles # GFS/PFUS/PFCN cloud lookup, GF normalize, integer LocalPreset id, @@ -139,8 +165,8 @@ async def apply_spool_to_slot_via_mqtt( tray_info_idx, setting_id, sub_brand_override, type_override = await resolve_slicer_filament( db=db, current_user=current_user, - slicer_filament=spool.slicer_filament, - slicer_filament_name=spool.slicer_filament_name, + slicer_filament=slot_slicer_filament, + slicer_filament_name=slot_slicer_filament_name, material=spool.material, ) if sub_brand_override: @@ -198,18 +224,7 @@ async def apply_spool_to_slot_via_mqtt( if spool.nozzle_temp_max is not None: temp_max = spool.nozzle_temp_max - nozzle_diameter = "0.4" - if state and state.nozzles: - nd = state.nozzles[0].nozzle_diameter - if nd: - nozzle_diameter = nd - - slot_extruder = None - if state and state.ams_extruder_map: - if ams_id == 255: - slot_extruder = 1 - tray_id # ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0 - else: - slot_extruder = state.ams_extruder_map.get(str(ams_id)) + slot_extruder = slot_nozzle.extruder # Prefer exact extruder match, fall back to extruder-agnostic kp for the # same nozzle. Hard-skipping on mismatch silently drops valid stored @@ -1667,6 +1682,73 @@ async def replace_k_profiles( return new_profiles +@router.get("/spools/{spool_id}/filament-presets", response_model=list[SpoolFilamentPresetResponse]) +async def list_filament_presets( + spool_id: int, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), +): + """List per-printer-model preset overrides for a spool. + + A dedicated endpoint rather than a field on ``SpoolResponse``: the + inventory list returns every spool the user owns, and only the spool form + and the assign path ever need this list, one spool at a time. + """ + result = await db.execute(select(SpoolFilamentPreset).where(SpoolFilamentPreset.spool_id == spool_id)) + return list(result.scalars().all()) + + +@router.put("/spools/{spool_id}/filament-presets", response_model=list[SpoolFilamentPresetResponse]) +async def replace_filament_presets( + spool_id: int, + presets: list[SpoolFilamentPresetBase], + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +): + """Replace all per-printer-model preset overrides for a spool. + + Replace rather than merge, matching the K-profile endpoint next door: the + spool form always holds the complete set, and an empty list is how the + user clears every override back to the spool's own preset. + """ + result = await db.execute(select(Spool).where(Spool.id == spool_id)) + if not result.scalar_one_or_none(): + raise HTTPException(404, "Spool not found") + + # (model, diameter) is UNIQUE, so a payload that names one twice would + # fail on flush with an IntegrityError the client cannot act on. Reject it + # by name instead -- and reject it BEFORE deleting the existing rows, so a + # bad request cannot wipe overrides it then fails to replace. + seen: set[tuple[str, str]] = set() + for p in presets: + key = (p.printer_model, p.nozzle_diameter) + if key in seen: + raise HTTPException( + 422, + f"Duplicate override for model {p.printer_model!r} nozzle {p.nozzle_diameter or 'any'!r}", + ) + seen.add(key) + + existing = await db.execute(select(SpoolFilamentPreset).where(SpoolFilamentPreset.spool_id == spool_id)) + for old in existing.scalars().all(): + await db.delete(old) + # Land the deletes before the inserts: within one transaction SQLAlchemy is + # free to order the INSERTs first, which trips the UNIQUE constraint + # against rows this call is about to remove. + await db.flush() + + new_presets = [] + for p in presets: + row = SpoolFilamentPreset(spool_id=spool_id, **p.model_dump()) + db.add(row) + new_presets.append(row) + + await db.commit() + for row in new_presets: + await db.refresh(row) + return new_presets + + # ── Spool Assignments ──────────────────────────────────────────────────────── diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index dd7eeff37..32a0954f5 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -87,6 +87,7 @@ from backend.app.services.printer_media import ( remove_printer_files_zip, start_printer_files_job, ) +from backend.app.services.slot_nozzle import resolve_slot_nozzle from backend.app.utils.filament_ids import filament_id_to_setting_id from backend.app.utils.filament_types import printer_filament_type from backend.app.utils.fts_routing import slot_extruder @@ -2651,6 +2652,105 @@ async def delete_slot_preset( return {"success": True} +@router.get("/{printer_id}/slots/{ams_id}/{tray_id}/spool-defaults") +async def get_slot_spool_defaults( + printer_id: int, + ams_id: int, + tray_id: int, + db: AsyncSession = Depends(get_db), + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), +): + """What the spool assigned to this slot is configured to use here. + + The Configure AMS Slot dialog opens on a slot that usually already holds an + assigned spool, and that spool carries a filament preset per printer model + and a K profile per hotend. Without this the dialog offered defaults derived + from the slot's last manual configuration or from the tray's RFID data -- + ignoring the very values the spool was configured with, on the one screen + that looks like it exists for them. + + Everything is resolved for the nozzle THIS slot feeds, so a dual-nozzle + machine gets the answer for the correct hotend. Returns nulls rather than a + 404 when the slot holds no known spool: "nothing configured" is an ordinary + answer here and the dialog falls back to what it did before. + """ + from backend.app.models.spool import Spool + from backend.app.models.spool_assignment import SpoolAssignment + from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment + from backend.app.services.inventory_mode import spoolman_owns_assignments + from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder + from backend.app.services.slot_nozzle import resolve_slot_nozzle + from backend.app.services.spool_filament_preset import resolve_spool_preset, resolve_spoolman_preset + + state = printer_manager.get_status(printer_id) + model = printer_manager.get_model(printer_id) + slot_nozzle = resolve_slot_nozzle(state, ams_id, tray_id, model) + + profile = await find_slot_kprofile_for_extruder( + db, + printer_id, + ams_id, + tray_id, + slot_nozzle.extruder_or_default, + slot_nozzle.diameter, + model, + ) + + slicer_filament: str | None = None + slicer_filament_name: str | None = None + spoolman_mode = await spoolman_owns_assignments(db) + if spoolman_mode: + sm_assignment = ( + await db.execute( + select(SpoolmanSlotAssignment).where( + SpoolmanSlotAssignment.printer_id == printer_id, + SpoolmanSlotAssignment.ams_id == ams_id, + SpoolmanSlotAssignment.tray_id == tray_id, + ) + ) + ).scalar_one_or_none() + if sm_assignment is not None: + slicer_filament, slicer_filament_name = await resolve_spoolman_preset( + db, + spoolman_spool_id=sm_assignment.spoolman_spool_id, + printer_model=model, + nozzle_diameter=slot_nozzle.diameter, + fallback_filament=None, + fallback_name=None, + ) + else: + assignment = ( + await db.execute( + select(SpoolAssignment).where( + SpoolAssignment.printer_id == printer_id, + SpoolAssignment.ams_id == ams_id, + SpoolAssignment.tray_id == tray_id, + ) + ) + ).scalar_one_or_none() + if assignment is not None: + spool = (await db.execute(select(Spool).where(Spool.id == assignment.spool_id))).scalar_one_or_none() + if spool is not None: + slicer_filament, slicer_filament_name = await resolve_spool_preset( + db, + spool_id=spool.id, + printer_model=model, + nozzle_diameter=slot_nozzle.diameter, + fallback_filament=spool.slicer_filament, + fallback_name=spool.slicer_filament_name, + ) + + return { + "slicer_filament": slicer_filament, + "slicer_filament_name": slicer_filament_name, + "cali_idx": profile.cali_idx if profile else None, + "k_value": profile.k_value if profile else None, + "profile_name": profile.name if profile else None, + "extruder": slot_nozzle.extruder, + "nozzle_diameter": slot_nozzle.diameter, + } + + @router.post("/{printer_id}/slots/{ams_id}/{tray_id}/configure") async def configure_ams_slot( printer_id: int, @@ -4169,13 +4269,12 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int): return # Compute nozzle/extruder once — used by both local and Spoolman lookup. - nozzle_diameter = "0.4" - if state.nozzles: - nd = state.nozzles[0].nozzle_diameter - if nd: - nozzle_diameter = nd - - resolved_extruder = slot_extruder(ams_id, slot_id, state.ams_extruder_map, state.ams_switch_inlet) + # Shared with every other slot-configuring path (services.slot_nozzle), + # so the diameter this cascade filters on is the one the slot's own + # hotend actually has. + slot_nozzle = resolve_slot_nozzle(state, ams_id, slot_id, printer_manager.get_model(printer_id)) + nozzle_diameter = slot_nozzle.diameter + resolved_extruder = slot_nozzle.extruder # 3-stage K-profile cascade: local SpoolKProfile → Spoolman SpoolmanKProfile # → live tray.cali_idx fallback. Pre-Phase-13 only handled the local path @@ -4214,7 +4313,7 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int): tag_filters.append(Spool.tag_uid == norm_tag) if tag_filters: tag_lookup = await db.execute( - sa_select(Spool).options(selectinload(Spool.k_profiles)).where(or_(*tag_filters)).limit(1) + select(Spool).options(selectinload(Spool.k_profiles)).where(or_(*tag_filters)).limit(1) ) spool = tag_lookup.scalar_one_or_none() if spool is not None: @@ -4256,7 +4355,7 @@ async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int): # including the tag-based fallback above) if matching_cali_idx is None and spool is None: sm_result = await db.execute( - sa_select(SpoolmanSlotAssignment).where( + select(SpoolmanSlotAssignment).where( SpoolmanSlotAssignment.printer_id == printer_id, SpoolmanSlotAssignment.ams_id == ams_id, SpoolmanSlotAssignment.tray_id == slot_id, diff --git a/backend/app/api/routes/spoolman.py b/backend/app/api/routes/spoolman.py index 8e49e322e..618f6f206 100644 --- a/backend/app/api/routes/spoolman.py +++ b/backend/app/api/routes/spoolman.py @@ -22,6 +22,9 @@ from backend.app.models.spoolman_k_profile import SpoolmanKProfile from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment from backend.app.models.user import User from backend.app.services.printer_manager import printer_manager +from backend.app.services.slicer_filament_resolver import resolve_slicer_filament +from backend.app.services.slot_nozzle import resolve_slot_nozzle +from backend.app.services.spool_filament_preset import resolve_spoolman_preset from backend.app.services.spoolman import ( SpoolmanClientError, SpoolmanNotFoundError, @@ -32,6 +35,7 @@ from backend.app.services.spoolman import ( ) from backend.app.utils.filament_ids import ( GENERIC_FILAMENT_IDS, + filament_id_to_setting_id, normalize_slicer_filament, ) from backend.app.utils.filament_types import nozzle_temp_range, printer_filament_type @@ -956,33 +960,61 @@ async def link_spool( if len(tray_color) == 6: tray_color = tray_color + "FF" + # Pull printer state via printer_manager (mqtt_client.printer_state + # was a non-existent attribute — the hasattr check silently + # returned None, defeating every state-based lookup below). + state = printer_manager.get_status(p_id) + slot_nozzle = resolve_slot_nozzle(state, a_id, t_id, printer_manager.get_model(p_id)) + nozzle_diameter = slot_nozzle.diameter + + # Resolve the spool's own preset before falling back to a + # generic material id. This path used to skip that entirely and + # configure every linked slot as generic PLA/PETG, so a spool + # with a preset set in inventory lost it the moment it was + # linked by tag — the same defect #1713 fixed on the assign + # path, in the function next door. The per-model override + # cascade applies here for the same reason it does there: the + # preset is bound to a printer model. + slot_slicer_filament, slot_slicer_filament_name = await resolve_spoolman_preset( + db, + spoolman_spool_id=spool_id, + printer_model=printer_manager.get_model(p_id), + nozzle_diameter=nozzle_diameter, + fallback_filament=mapped.get("slicer_filament"), + fallback_name=mapped.get("slicer_filament_name"), + ) + tray_info_idx, setting_id, sub_brand_override, type_override = await resolve_slicer_filament( + db=db, + current_user=None, + slicer_filament=slot_slicer_filament, + slicer_filament_name=slot_slicer_filament_name, + material=material, + ) + if sub_brand_override: + tray_sub_brands = sub_brand_override + if type_override: + tray_type = printer_filament_type(type_override) + # The spool's own wording is tried first and the reduced type # only as a further fallback, so a material that already # resolves keeps resolving to the same id: "PETG HF" has its # own generic preset (GFG96) that reducing it to "PETG" would # trade away for GFG99. material_upper = material.upper().strip() - tray_info_idx = ( - GENERIC_FILAMENT_IDS.get(material_upper) - or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0]) - or GENERIC_FILAMENT_IDS.get(tray_type.upper()) - or "" - ) - setting_id = "" + if not tray_info_idx: + tray_info_idx = ( + GENERIC_FILAMENT_IDS.get(material_upper) + or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0]) + or GENERIC_FILAMENT_IDS.get(tray_type.upper()) + or "" + ) + if tray_info_idx and not setting_id: + setting_id = filament_id_to_setting_id(tray_info_idx) + temp_defaults = nozzle_temp_range(material, tray_type) temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0] temp_max = temp_defaults[1] - # Pull printer state via printer_manager (mqtt_client.printer_state - # was a non-existent attribute — the hasattr check silently - # returned None, defeating every state-based lookup below). - state = printer_manager.get_status(p_id) - nozzle_diameter = "0.4" - if state and state.nozzles: - nd = state.nozzles[0].nozzle_diameter - if nd: - nozzle_diameter = nd - kp_result = await db.execute( select(SpoolmanKProfile).where( SpoolmanKProfile.spoolman_spool_id == spool_id, @@ -990,12 +1022,7 @@ async def link_spool( ) ) kp_rows = kp_result.scalars().all() - slot_extruder = None - if state and state.ams_extruder_map: - if a_id == 255: - slot_extruder = 1 - t_id - else: - slot_extruder = state.ams_extruder_map.get(str(a_id)) + slot_extruder = slot_nozzle.extruder # Prefer exact extruder match, fall back to extruder-agnostic kp # for the same nozzle. Hard-skip on extruder mismatch silently diff --git a/backend/app/api/routes/spoolman_inventory.py b/backend/app/api/routes/spoolman_inventory.py index 97e239449..9d1d0b837 100644 --- a/backend/app/api/routes/spoolman_inventory.py +++ b/backend/app/api/routes/spoolman_inventory.py @@ -38,10 +38,11 @@ from backend.app.core.websocket import ws_manager from backend.app.models.ams_label import AmsLabel from backend.app.models.printer import Printer from backend.app.models.settings import Settings +from backend.app.models.spool_filament_preset import SpoolmanFilamentPreset from backend.app.models.spoolman_k_profile import SpoolmanKProfile from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment from backend.app.models.user import User -from backend.app.schemas.spool import SpoolKProfileBase +from backend.app.schemas.spool import SpoolFilamentPresetBase, SpoolKProfileBase from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched from backend.app.services.location_service import ( enrich_spool_dicts_with_location_id, @@ -50,6 +51,8 @@ from backend.app.services.location_service import ( ) from backend.app.services.printer_manager import printer_manager from backend.app.services.slicer_filament_resolver import resolve_slicer_filament +from backend.app.services.slot_nozzle import resolve_slot_nozzle +from backend.app.services.spool_filament_preset import resolve_spoolman_preset from backend.app.services.spoolman import ( SpoolmanClient, SpoolmanClientError, @@ -1507,6 +1510,33 @@ async def assign_spoolman_slot( if len(tray_color) == 6: tray_color = tray_color + "FF" + # Printer state, read here rather than further down because the + # per-model preset override below needs the slot's nozzle + # diameter and the K-profile cascade further down needs the same + # value -- one read, so they cannot disagree. (The previous + # `mqtt_client.printer_state` access via hasattr always returned + # None -- the attribute is `state`, not `printer_state` -- so the + # K-profile cascade silently skipped state.kprofiles, defaulted + # nozzle_diameter to 0.4, and left slot_extruder unset.) + state = printer_manager.get_status(body.printer_id) + slot_nozzle = resolve_slot_nozzle( + state, body.ams_id, body.tray_id, printer_manager.get_model(body.printer_id) + ) + nozzle_diameter = slot_nozzle.diameter + + # Per-printer-model preset override, same cascade as internal + # mode: a cloud/Orca preset is bound to a model, so one stored + # preset per spool is wrong across two models. Returns Spoolman's + # own value when no override is set. + slot_slicer_filament, slot_slicer_filament_name = await resolve_spoolman_preset( + db, + spoolman_spool_id=body.spoolman_spool_id, + printer_model=printer_manager.get_model(body.printer_id), + nozzle_diameter=nozzle_diameter, + fallback_filament=mapped.get("slicer_filament"), + fallback_name=mapped.get("slicer_filament_name"), + ) + # #1713: resolve the spool's stored slicer_filament reference # (cloud preset, local preset, GF-prefix builtin, or numeric # LocalPreset id) to the printer-side tray_info_idx + setting_id. @@ -1518,8 +1548,8 @@ async def assign_spoolman_slot( tray_info_idx, setting_id, sub_brand_override, type_override = await resolve_slicer_filament( db=db, current_user=current_user, - slicer_filament=mapped.get("slicer_filament"), - slicer_filament_name=mapped.get("slicer_filament_name"), + slicer_filament=slot_slicer_filament, + slicer_filament_name=slot_slicer_filament_name, material=material, ) if sub_brand_override: @@ -1563,21 +1593,7 @@ async def assign_spoolman_slot( # None (the attribute is `state`, not `printer_state`), so the # K-profile cascade silently skipped state.kprofiles, defaulted # nozzle_diameter to 0.4, and left slot_extruder unset. - state = printer_manager.get_status(body.printer_id) - nozzle_diameter = "0.4" - if state and state.nozzles: - nd = state.nozzles[0].nozzle_diameter - if nd: - nozzle_diameter = nd - - slot_extruder = None - if state and state.ams_extruder_map: - if body.ams_id == 255: - # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0 - # tray_id 0→1, 1→0 - slot_extruder = 1 - body.tray_id - else: - slot_extruder = state.ams_extruder_map.get(str(body.ams_id)) + slot_extruder = slot_nozzle.extruder # Prefer exact extruder match, fall back to extruder-agnostic kp # for the same nozzle. Hard-skipping on mismatch silently dropped @@ -1820,6 +1836,89 @@ def _k_profile_to_dict(p: SpoolmanKProfile) -> dict: } +def _filament_preset_to_dict(p: SpoolmanFilamentPreset) -> dict: + """Manually map SpoolmanFilamentPreset → SpoolFilamentPresetResponse-compatible dict.""" + return { + "id": p.id, + "spool_id": p.spoolman_spool_id, + "printer_model": p.printer_model, + "nozzle_diameter": p.nozzle_diameter, + "slicer_filament": p.slicer_filament, + "slicer_filament_name": p.slicer_filament_name, + "created_at": p.created_at, + } + + +@router.get("/spools/{spool_id}/filament-presets") +async def get_spoolman_filament_presets( + spool_id: int = Path(..., gt=0), + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), +) -> list[dict]: + """Return all per-printer-model preset overrides for a Spoolman spool.""" + await _get_client(db) + result = await db.execute( + select(SpoolmanFilamentPreset).where(SpoolmanFilamentPreset.spoolman_spool_id == spool_id) + ) + return [_filament_preset_to_dict(p) for p in result.scalars().all()] + + +@router.put("/spools/{spool_id}/filament-presets") +async def save_spoolman_filament_presets( + spool_id: int = Path(..., gt=0), + presets: list[SpoolFilamentPresetBase] = Body(...), + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +) -> list[dict]: + """Replace all per-printer-model preset overrides for a Spoolman spool.""" + client = await _get_client(db) + async with _translate_spoolman_errors(): + await client.get_spool(spool_id) + + # Same as the internal route: reject a duplicated (model, diameter) before + # touching the stored rows, so a bad payload cannot clear what it fails to + # replace. + seen: set[tuple[str, str]] = set() + for preset in presets: + key = (preset.printer_model, preset.nozzle_diameter) + if key in seen: + raise HTTPException( + 422, + f"Duplicate override for model {preset.printer_model!r} nozzle {preset.nozzle_diameter or 'any'!r}", + ) + seen.add(key) + + saved: list[SpoolmanFilamentPreset] = [] + try: + await db.execute(delete(SpoolmanFilamentPreset).where(SpoolmanFilamentPreset.spoolman_spool_id == spool_id)) + await db.flush() + for preset in presets: + obj = SpoolmanFilamentPreset( + spoolman_spool_id=spool_id, + printer_model=preset.printer_model, + nozzle_diameter=preset.nozzle_diameter, + slicer_filament=preset.slicer_filament, + slicer_filament_name=preset.slicer_filament_name, + ) + db.add(obj) + saved.append(obj) + await db.commit() + except IntegrityError as exc: + await db.rollback() + raise HTTPException(422, "Duplicate or invalid preset override (check model and nozzle uniqueness)") from exc + except HTTPException: + raise + except Exception as exc: + await db.rollback() + logger.error("Filament preset save for spool %d failed: %s", spool_id, exc) + raise HTTPException(500, "Failed to save filament presets") from exc + + for obj in saved: + await db.refresh(obj) + + return [_filament_preset_to_dict(p) for p in saved] + + def _normalize_filament(raw: dict) -> NormalizedFilament | None: """Normalise a raw Spoolman filament dict for the frontend catalog picker. diff --git a/backend/app/core/database.py b/backend/app/core/database.py index f82cac9ae..92aaa4079 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -330,6 +330,7 @@ async def init_db(): spool, spool_assignment, spool_catalog, + spool_filament_preset, spool_k_profile, spool_usage_history, spoolbuddy_device, diff --git a/backend/app/main.py b/backend/app/main.py index bb878ebab..510e150ae 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -128,10 +128,12 @@ from backend.app.services.printer_manager import ( resolve_plate_id, ) from backend.app.services.slot_kprofile import find_slot_kprofile_for_extruder +from backend.app.services.slot_nozzle import nozzle_diameter_for_extruder, resolve_slot_nozzle from backend.app.services.smart_plug_manager import smart_plug_manager from backend.app.services.spool_assignment_notifications import ( notify_missing_spool_assignments_on_print_start, ) +from backend.app.services.spool_filament_preset import printer_safe_filament_id from backend.app.services.spoolman import close_spoolman_client, get_spoolman_client, init_spoolman_client from backend.app.services.spoolman_tracking import ( cleanup_tracking as _cleanup_spoolman_tracking, @@ -141,7 +143,7 @@ from backend.app.services.spoolman_tracking import ( from backend.app.services.tasmota import tasmota_service from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed from backend.app.utils.filament_types import printer_filament_type -from backend.app.utils.fts_routing import extruder_for_inlet, slot_extruder as resolve_slot_extruder +from backend.app.utils.fts_routing import extruder_for_inlet from backend.app.utils.local_time import utcnow_naive from backend.app.utils.print_jobs import is_internal_printer_job @@ -1892,9 +1894,11 @@ async def on_fts_inlet_change(printer_id: int, ams_id: int, inlet: str): if not client or not state or not state.raw_data: return - nozzle_diameter = "0.4" - if state.nozzles and state.nozzles[0].nozzle_diameter: - nozzle_diameter = state.nozzles[0].nozzle_diameter + # The nozzle the AMS now feeds -- the diameter of the TARGET extruder, not + # of nozzle 0. On a machine with two sizes fitted, moving the inlet changes + # the nozzle width, which changes both the K profile to select and the + # preset the slot should carry. + nozzle_diameter = nozzle_diameter_for_extruder(state, target_extruder, printer_manager.get_model(printer_id)) ams_raw = state.raw_data.get("ams") ams_list = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else [] @@ -1911,7 +1915,13 @@ async def on_fts_inlet_change(printer_id: int, ams_id: int, inlet: str): current_idx = tray.get("cali_idx") profile = await find_slot_kprofile_for_extruder( - db, printer_id, ams_id, tray_id, target_extruder, nozzle_diameter + db, + printer_id, + ams_id, + tray_id, + target_extruder, + nozzle_diameter, + printer_manager.get_model(printer_id), ) if profile is None or profile.cali_idx is None: continue @@ -1935,7 +1945,7 @@ async def on_fts_inlet_change(printer_id: int, ams_id: int, inlet: str): ams_id=ams_id, tray_id=tray_id, cali_idx=profile.cali_idx, - filament_id=profile.filament_id or tray.get("tray_info_idx", "") or "", + filament_id=printer_safe_filament_id(profile.filament_id, tray.get("tray_info_idx", "")), nozzle_diameter=nozzle_diameter, ) except Exception as e: @@ -2364,17 +2374,11 @@ async def on_ams_change(printer_id: int, ams_data: list): and spool.k_profiles ): state = printer_manager.get_status(printer_id) - nozzle_diameter = "0.4" - if state and state.nozzles: - nd = state.nozzles[0].nozzle_diameter - if nd: - nozzle_diameter = nd - slot_extruder = resolve_slot_extruder( - ams_id, - tray_id, - state.ams_extruder_map if state else None, - state.ams_switch_inlet if state else None, + slot_nozzle = resolve_slot_nozzle( + state, ams_id, tray_id, printer_manager.get_model(printer_id) ) + nozzle_diameter = slot_nozzle.diameter + slot_extruder = slot_nozzle.extruder # Prefer exact extruder match, fall back to # extruder-agnostic kp for the same printer + # nozzle. Avoids hard-skipping when the AMS is diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index fad0abda7..c39bdab0a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -34,6 +34,7 @@ from backend.app.models.sponsor_toast_state import SponsorToastState from backend.app.models.spool import Spool from backend.app.models.spool_assignment import SpoolAssignment from backend.app.models.spool_catalog import SpoolCatalogEntry +from backend.app.models.spool_filament_preset import SpoolFilamentPreset, SpoolmanFilamentPreset from backend.app.models.spool_k_profile import SpoolKProfile from backend.app.models.spool_usage_history import SpoolUsageHistory from backend.app.models.spoolbuddy_device import SpoolBuddyDevice @@ -82,7 +83,9 @@ __all__ = [ "PipelineRun", "SlicerPipeline", "Spool", + "SpoolFilamentPreset", "SpoolKProfile", + "SpoolmanFilamentPreset", "SpoolAssignment", "SpoolCatalogEntry", "SpoolUsageHistory", diff --git a/backend/app/models/spool.py b/backend/app/models/spool.py index 3fde2b9ba..294c55ee3 100644 --- a/backend/app/models/spool.py +++ b/backend/app/models/spool.py @@ -74,10 +74,18 @@ class Spool(Base): updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) k_profiles: Mapped[list["SpoolKProfile"]] = relationship(back_populates="spool", cascade="all, delete-orphan") + # Per-printer-model preset overrides. Deliberately NOT embedded in + # SpoolResponse the way k_profiles is: the inventory list returns every + # spool a user owns, and this list is only ever read by the spool form + # and the assign path, both of which fetch it for one spool at a time. + filament_presets: Mapped[list["SpoolFilamentPreset"]] = relationship( + back_populates="spool", cascade="all, delete-orphan" + ) assignments: Mapped[list["SpoolAssignment"]] = relationship(back_populates="spool", cascade="all, delete-orphan") location: Mapped["Location | None"] = relationship(back_populates="spools") from backend.app.models.location import Location # noqa: E402 from backend.app.models.spool_assignment import SpoolAssignment # noqa: E402 +from backend.app.models.spool_filament_preset import SpoolFilamentPreset # noqa: E402 from backend.app.models.spool_k_profile import SpoolKProfile # noqa: E402 diff --git a/backend/app/models/spool_filament_preset.py b/backend/app/models/spool_filament_preset.py new file mode 100644 index 000000000..c125c7d97 --- /dev/null +++ b/backend/app/models/spool_filament_preset.py @@ -0,0 +1,90 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from backend.app.core.database import Base + + +class SpoolFilamentPreset(Base): + """Per-printer-model override of a spool's slicer filament preset. + + ``Spool.slicer_filament`` holds ONE preset, and that is deliberate -- the + spool form is printer-agnostic and the user picks the variant they want + (see ``spool-form/utils.ts``). It stops being enough as soon as the same + spool is used on two different printer models: a cloud or Orca preset is + bound to a model (``@BBL X1C``), so the spool that carries an X1C variant + configures an AMS slot on an H2C with a preset that machine has no profile + for. + + Keyed on the printer MODEL, not the printer: ``@BBL X1C`` is the same + preset on every X1C the user owns, and keying per machine would make them + pick the identical value once per printer. (K profiles are the opposite -- + a K value is measured on one individual hotend -- which is why + ``spool_k_profile`` keys on ``printer_id`` and this does not.) + + ``nozzle_diameter`` is part of the key because the preset lands on an AMS + slot, and a slot feeds exactly one nozzle: on a dual-nozzle machine with + two different diameters fitted, one preset per model cannot be right for + both hotends, and diameter-specific presets genuinely exist + (``Bambu PLA Basic @BBL A1M 0.2 nozzle``). Empty string means "any nozzle + of this model". The spool form does not write that row -- it offers one row + per nozzle size and nothing above them, because a preset lands on an AMS + slot and a slot feeds exactly one nozzle -- but the level is kept in the + cascade for API clients that want one value to cover a whole model. + Resolution order is + + exact (model, diameter) -> (model, "") -> ``Spool.slicer_filament`` + + which is what ``services.spool_filament_preset.resolve_spool_preset`` + implements. Empty string rather than NULL because NULLs compare distinct + in a UNIQUE constraint on both SQLite and PostgreSQL, so a nullable + column would happily store the same "any nozzle" row twice. + """ + + __tablename__ = "spool_filament_preset" + + __table_args__ = (UniqueConstraint("spool_id", "printer_model", "nozzle_diameter"),) + + id: Mapped[int] = mapped_column(primary_key=True) + spool_id: Mapped[int] = mapped_column(ForeignKey("spool.id", ondelete="CASCADE"), index=True) + # Matches ``printers.model`` ("X1C", "H2D", "A1 mini"), not a display name. + printer_model: Mapped[str] = mapped_column(String(50)) + # "" = any nozzle of this model; otherwise the bare decimal the printer + # reports ("0.4", "0.2"), the same form ``spool_k_profile`` stores. + nozzle_diameter: Mapped[str] = mapped_column(String(10), default="") + # Wider than ``Spool.slicer_filament`` (String(50)) on purpose: the same + # values reach the Spoolman path, whose write schema already allows 128 / + # 255, and a preset id that fits there must not truncate here. + slicer_filament: Mapped[str | None] = mapped_column(String(128)) + slicer_filament_name: Mapped[str | None] = mapped_column(String(255)) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + spool: Mapped["Spool"] = relationship(back_populates="filament_presets") + + +class SpoolmanFilamentPreset(Base): + """``SpoolFilamentPreset`` for a Spoolman-managed spool. + + Mirrors ``SpoolmanKProfile``: Spoolman owns the spool, Bambuddy owns this + override, so the row is local and keyed by the remote spool id with no + foreign key to enforce it. Kept in a Bambuddy table rather than in the + spool's Spoolman ``extra`` dict for the same reason the K profiles are -- + it is Bambu-specific data that no other Spoolman client can use, and the + extra dict cannot express a per-model list without hand-rolled JSON. + """ + + __tablename__ = "spoolman_filament_preset" + + __table_args__ = (UniqueConstraint("spoolman_spool_id", "printer_model", "nozzle_diameter"),) + + id: Mapped[int] = mapped_column(primary_key=True) + spoolman_spool_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + printer_model: Mapped[str] = mapped_column(String(50)) + nozzle_diameter: Mapped[str] = mapped_column(String(10), default="") + slicer_filament: Mapped[str | None] = mapped_column(String(128)) + slicer_filament_name: Mapped[str | None] = mapped_column(String(255)) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + +from backend.app.models.spool import Spool # noqa: E402, F401 diff --git a/backend/app/schemas/spool.py b/backend/app/schemas/spool.py index 9a2a7c930..95047d22a 100644 --- a/backend/app/schemas/spool.py +++ b/backend/app/schemas/spool.py @@ -198,6 +198,31 @@ class SpoolKProfileResponse(SpoolKProfileBase): from_attributes = True +class SpoolFilamentPresetBase(BaseModel): + """One per-printer-model slicer preset override for a spool. + + ``nozzle_diameter`` defaults to "" meaning "any nozzle of this model". The + spool form always sends a concrete size; the empty form is for API clients + that want one value to cover a model. Lengths match the columns, which are wider than + ``Spool.slicer_filament`` so a preset id that fits the Spoolman write + schema cannot truncate on the way in. + """ + + printer_model: str = Field(..., min_length=1, max_length=50) + nozzle_diameter: str = Field(default="", max_length=10) + slicer_filament: str | None = Field(default=None, max_length=128) + slicer_filament_name: str | None = Field(default=None, max_length=255) + + +class SpoolFilamentPresetResponse(SpoolFilamentPresetBase): + id: int + spool_id: int + created_at: datetime + + class Config: + from_attributes = True + + class SpoolResponse(SpoolBase): id: int # rgba is intentionally unconstrained on the response side: the write paths diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index e8e229c3a..d1806afe4 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -853,7 +853,12 @@ class PrinterState: wifi_signal: int | None = None # WiFi signal strength in dBm wired_network: bool = False # Ethernet connection detected (home_flag bit 18) door_open: bool = False # Enclosure door open (home_flag bit 23; models with a door sensor: X1/X1C/X1E/X2D/P2S/H2*) - # Nozzle hardware info (for dual nozzle printers, index 0 = left, 1 = right) + # Nozzle hardware info. Indexed by EXTRUDER id: [0] is the RIGHT hotend and + # [1] the left, measured 2026-08-27 on an H2D fitted with 0.4 left / 0.6 + # right. (The legacy parser below writes left -> [0], but it only ever runs + # for single-nozzle printers -- every dual-nozzle model reports + # device.nozzle.info instead.) Read it through services.slot_nozzle rather + # than indexing it directly. nozzles: list = field(default_factory=lambda: [NozzleInfo(), NozzleInfo()]) # AI detection and print options print_options: PrintOptions = field(default_factory=PrintOptions) diff --git a/backend/app/services/slot_kprofile.py b/backend/app/services/slot_kprofile.py index 891df582d..da045e2a6 100644 --- a/backend/app/services/slot_kprofile.py +++ b/backend/app/services/slot_kprofile.py @@ -23,6 +23,7 @@ from backend.app.models.spool_k_profile import SpoolKProfile from backend.app.models.spoolman_k_profile import SpoolmanKProfile from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment from backend.app.services.inventory_mode import spoolman_owns_assignments +from backend.app.services.spool_filament_preset import resolve_spool_preset, resolve_spoolman_preset @dataclass(frozen=True) @@ -45,6 +46,7 @@ async def find_slot_kprofile_for_extruder( tray_id: int, extruder: int, nozzle_diameter: str, + printer_model: str | None = None, ) -> SlotKProfile | None: """Stored profile for whatever is in this slot, calibrated for ``extruder``. @@ -93,12 +95,27 @@ async def find_slot_kprofile_for_extruder( ) if profile is not None: spool = (await db.execute(select(Spool).where(Spool.id == assignment.spool_id))).scalar_one_or_none() + # The preset this profile was calibrated under, through the + # per-printer-model cascade: a spool can carry a different preset + # per model, and extrusion_cali_sel has to name the one the printer + # will actually see in the slot. Falls back to the spool's own + # value when the caller cannot say which model this is. + filament_id = spool.slicer_filament if spool else None + if spool is not None and printer_model: + filament_id, _ = await resolve_spool_preset( + db, + spool_id=spool.id, + printer_model=printer_model, + nozzle_diameter=nozzle_diameter, + fallback_filament=spool.slicer_filament, + fallback_name=spool.slicer_filament_name, + ) return SlotKProfile( cali_idx=profile.cali_idx, k_value=profile.k_value, name=profile.name, extruder=profile.extruder, - filament_id=(spool.slicer_filament if spool else None), + filament_id=filament_id, ) # A known spool with no profile for this nozzle is a deliberate stop: # falling through to Spoolman would answer for a different spool. @@ -136,12 +153,24 @@ async def find_slot_kprofile_for_extruder( if sm_profile is None: return None - # Spoolman rows carry no slicer preset; the caller falls back to the tray's - # own tray_info_idx for filament_id. + # A Spoolman K row carries no preset of its own, but the spool can still + # have a per-model override stored locally -- that is the same table the + # Spoolman assign path writes. Without a model to key on there is nothing + # to resolve and the caller falls back to the tray's own tray_info_idx. + sm_filament_id = None + if printer_model: + sm_filament_id, _ = await resolve_spoolman_preset( + db, + spoolman_spool_id=sm_assignment.spoolman_spool_id, + printer_model=printer_model, + nozzle_diameter=nozzle_diameter, + fallback_filament=None, + fallback_name=None, + ) return SlotKProfile( cali_idx=sm_profile.cali_idx, k_value=sm_profile.k_value, name=sm_profile.name, extruder=sm_profile.extruder, - filament_id=None, + filament_id=sm_filament_id, ) diff --git a/backend/app/services/slot_nozzle.py b/backend/app/services/slot_nozzle.py new file mode 100644 index 000000000..ec91752c6 --- /dev/null +++ b/backend/app/services/slot_nozzle.py @@ -0,0 +1,117 @@ +"""Which nozzle does this AMS slot feed, and how wide is it? + +Every path that configures a slot needs the same two facts: the extruder the +slot feeds, and that nozzle's diameter. Both the filament preset and the K +profile are stored per nozzle diameter, so getting the diameter wrong silently +selects the wrong preset *and* the wrong K value -- and before this module the +answer was worked out independently in seven places, each with ``nozzles[0]`` +hard-coded as the diameter for every slot on the machine. + +``nozzles[0]`` is correct on a single-nozzle printer and correct on a +dual-nozzle printer with the same size fitted both sides, which is why it has +survived. It is wrong the moment someone fits a 0.4 and a 0.2, which is exactly +the machine this feature exists for. + +## Which array index belongs to which extruder + +``PrinterState.nozzles`` is filled by two different MQTT parsers that use +opposite conventions, and this module is where that is resolved once: + +* The **H2/X2 path** (``bambu_mqtt`` ~5100) writes ``nozzles[nozzle["id"]]`` + straight from ``device.nozzle.info``, i.e. indexed by physical nozzle id. +* The **legacy path** (~5013) writes left -> ``nozzles[0]``, right -> + ``nozzles[1]``, which is the reverse of the extruder ids (extruder 0 is the + RIGHT hotend). + +**MEASURED 2026-08-27 on an H2D with 0.4 high flow LEFT and 0.6 high flow +RIGHT: ``nozzles[0]`` read 0.6 -- the right hotend, which is extruder 0.** So +the array is indexed by extruder id, and the H2 convention (physical nozzle id N +sits on extruder N) is the one that holds. + +The legacy branch cannot govern a real dual-nozzle machine anyway: every model +in ``DUAL_NOZZLE_MODELS`` is H2-series or X2D, all of which report +``device.nozzle.info``, and ``left_nozzle_diameter`` appears nowhere in any +captured log or wire trace. On a single-nozzle printer both conventions agree +that index 0 is the only nozzle. + +The distinction is invisible on a machine with matching nozzles, since both +conventions then return the same string -- which is why it went unnoticed for so +long, and why this is the single place to change if a future model contradicts +it. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from backend.app.utils.fts_routing import slot_extruder +from backend.app.utils.printer_models import is_dual_nozzle_model + +logger = logging.getLogger(__name__) + +# What a printer that has told us nothing is assumed to have fitted. Matches +# the default every call site used before this module existed. +DEFAULT_NOZZLE_DIAMETER = "0.4" + + +@dataclass(frozen=True) +class SlotNozzle: + """The nozzle an AMS slot feeds.""" + + # None when the printer has not said which extruder this slot feeds. Callers + # that must have a number use ``extruder_or_default``; callers that store a + # row keep the None so "unknown" is not written as "the right-hand nozzle". + extruder: int | None + diameter: str + + @property + def extruder_or_default(self) -> int: + """0 when unknown -- correct on a single-nozzle machine, a guess on a dual.""" + return 0 if self.extruder is None else self.extruder + + +def nozzle_diameter_for_extruder(state, extruder: int | None, model: str | None = None) -> str: + """The diameter fitted to ``extruder``, or the printer's only nozzle. + + Falls back to index 0, and then to 0.4, whenever the printer has not + reported the entry -- an absent nozzle must not make this raise, since it is + called on every assign. + """ + nozzles = getattr(state, "nozzles", None) or [] + if not nozzles: + return DEFAULT_NOZZLE_DIAMETER + + index = 0 + if extruder is not None and extruder > 0 and is_dual_nozzle_model(model): + # Physical nozzle id N sits on extruder N -- see the module docstring + # for why the legacy left/right convention cannot apply here. + index = extruder + + for candidate in (index, 0): + if candidate < len(nozzles): + diameter = (getattr(nozzles[candidate], "nozzle_diameter", "") or "").strip() + if diameter: + return diameter + return DEFAULT_NOZZLE_DIAMETER + + +def resolve_slot_nozzle(state, ams_id: int, tray_id: int, model: str | None = None) -> SlotNozzle: + """The extruder an AMS slot feeds and that nozzle's diameter. + + ``state`` is the live ``PrinterState`` (or None when the printer is not + connected, which yields the defaults rather than an error). + """ + if state is None: + return SlotNozzle(extruder=None, diameter=DEFAULT_NOZZLE_DIAMETER) + + extruder = slot_extruder( + ams_id, + tray_id, + getattr(state, "ams_extruder_map", None), + getattr(state, "ams_switch_inlet", None), + ) + return SlotNozzle( + extruder=extruder, + diameter=nozzle_diameter_for_extruder(state, extruder, model), + ) diff --git a/backend/app/services/spool_filament_preset.py b/backend/app/services/spool_filament_preset.py new file mode 100644 index 000000000..86f1abda9 --- /dev/null +++ b/backend/app/services/spool_filament_preset.py @@ -0,0 +1,127 @@ +"""Resolve which slicer filament preset a spool should use on a given nozzle. + +``Spool.slicer_filament`` is the spool's single, printer-agnostic answer. It is +right until the same spool is used on two printer models, because a cloud or +Orca preset is bound to a model (``@BBL X1C``): assigning that spool to an H2C +writes a slot preset the H2C has no profile for. ``SpoolFilamentPreset`` stores +the per-model exceptions and this module is the only thing that reads them, so +the internal-inventory and Spoolman-inventory assign paths cannot drift apart +the way they did before #1713. + +Resolution order, most specific first: + + 1. (printer_model, nozzle_diameter) -- what the spool form writes, one + row per nozzle size + 2. (printer_model, "") -- a whole-model value; the form does + not write these, but the API accepts + them and they still resolve + 3. ``Spool.slicer_filament`` -- what the spool carries today + +Every step is a plain equality match on stored strings; nothing is inferred +from preset names. A model with no row at all resolves to step 3, which is +exactly the behaviour every install has now, so a spool nobody has configured +per-model behaves identically before and after this feature. +""" + +from __future__ import annotations + +import logging + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.models.spool_filament_preset import SpoolFilamentPreset, SpoolmanFilamentPreset + +logger = logging.getLogger(__name__) + +# What ``resolve_*`` returns: (slicer_filament, slicer_filament_name). +PresetPair = tuple[str | None, str | None] + + +def _pick( + rows: list[SpoolFilamentPreset] | list[SpoolmanFilamentPreset], + printer_model: str | None, + nozzle_diameter: str | None, + fallback: PresetPair, +) -> PresetPair: + """Apply the cascade to rows already fetched for one spool. + + Split out so both spool flavours share it, and so callers that already + hold the rows (the spool form's read path) do not re-query. + """ + model = (printer_model or "").strip() + if not model: + # No model means no way to be more specific than the spool's own value. + # This is the normal answer for a printer that has not reported yet. + return fallback + + diameter = (nozzle_diameter or "").strip() + exact: PresetPair | None = None + model_default: PresetPair | None = None + + for row in rows: + if row.printer_model != model: + continue + if diameter and row.nozzle_diameter == diameter: + exact = (row.slicer_filament, row.slicer_filament_name) + elif row.nozzle_diameter == "": + model_default = (row.slicer_filament, row.slicer_filament_name) + + chosen = exact or model_default + if chosen is None: + return fallback + # A row that exists but carries no preset id is a deliberate "use nothing + # here", not a hole to fall through: the user picked the blank entry for + # this model. Falling back would silently reinstate the value they cleared. + return chosen + + +def printer_safe_filament_id(*candidates: str | None) -> str: + """First candidate the printer will accept as a filament id, or "". + + ``extrusion_cali_sel`` carries a filament id so the printer can link the + calibration index to the slot's filament. A cloud USER preset id + (``PFUS``/``PFCN`` prefix) is not one the slicer accepts -- the assign paths + have refused those for tray_info_idx since #1713, and the same holds here. + + This matters now that a per-model override can BE such an id: a user picking + their own cloud preset for a model stores its ``PFUS...`` id, and passing + that straight through would send the printer a value it rejects, silently + losing the K-profile link. Falls through to the next candidate instead -- + normally the spool's own preset, then the tray's RFID value. + """ + for candidate in candidates: + value = (candidate or "").strip() + if value and not value.startswith(("PFUS", "PFCN")): + return value + return "" + + +async def resolve_spool_preset( + db: AsyncSession, + *, + spool_id: int, + printer_model: str | None, + nozzle_diameter: str | None, + fallback_filament: str | None, + fallback_name: str | None, +) -> PresetPair: + """Cascade for an internal-inventory spool. See the module docstring.""" + result = await db.execute(select(SpoolFilamentPreset).where(SpoolFilamentPreset.spool_id == spool_id)) + return _pick(list(result.scalars().all()), printer_model, nozzle_diameter, (fallback_filament, fallback_name)) + + +async def resolve_spoolman_preset( + db: AsyncSession, + *, + spoolman_spool_id: int, + printer_model: str | None, + nozzle_diameter: str | None, + fallback_filament: str | None, + fallback_name: str | None, +) -> PresetPair: + """Cascade for a Spoolman-managed spool. See the module docstring.""" + result = await db.execute( + select(SpoolmanFilamentPreset).where(SpoolmanFilamentPreset.spoolman_spool_id == spoolman_spool_id) + ) + return _pick(list(result.scalars().all()), printer_model, nozzle_diameter, (fallback_filament, fallback_name)) diff --git a/backend/app/services/spool_tag_matcher.py b/backend/app/services/spool_tag_matcher.py index 4fb7476e9..8778a7120 100644 --- a/backend/app/services/spool_tag_matcher.py +++ b/backend/app/services/spool_tag_matcher.py @@ -9,6 +9,8 @@ from sqlalchemy.orm import selectinload from backend.app.models.spool import Spool from backend.app.models.spool_assignment import SpoolAssignment from backend.app.schemas.spool import normalize_effect_type +from backend.app.services.slot_nozzle import resolve_slot_nozzle +from backend.app.services.spool_filament_preset import printer_safe_filament_id, resolve_spool_preset from backend.app.utils.tag_normalization import ( normalize_tag_uid as _normalize_tag_uid, normalize_tray_uuid as _normalize_tray_uuid, @@ -566,24 +568,47 @@ async def auto_assign_spool( try: client = printer_manager.get_client(printer_id) if client: - # Apply K-profile if available - nozzle_diameter = "0.4" - if state and state.nozzles: - nd = state.nozzles[0].nozzle_diameter - if nd: - nozzle_diameter = nd + # Which nozzle this slot feeds, resolved the same way every other + # slot-configuring path resolves it (services.slot_nozzle). + slot_nozzle = resolve_slot_nozzle(state, ams_id, tray_id, printer_manager.get_model(printer_id)) + nozzle_diameter = slot_nozzle.diameter - matching_kp = None + # Prefer the profile calibrated for THIS hotend, falling back to one + # stored for the same nozzle size on the other. Before this the + # first row matching (printer, diameter) won outright with no + # extruder test at all -- on a dual-nozzle printer that is a coin + # toss between the two hotends, on the path that fires unattended + # every time an RFID spool is loaded. + exact_kp = None + fallback_kp = None for kp in spool.k_profiles: - if kp.printer_id == printer_id and kp.nozzle_diameter == nozzle_diameter: - matching_kp = kp + if kp.printer_id != printer_id or kp.nozzle_diameter != nozzle_diameter: + continue + if slot_nozzle.extruder is not None and kp.extruder == slot_nozzle.extruder: + exact_kp = kp break + if fallback_kp is None: + fallback_kp = kp + matching_kp = exact_kp or fallback_kp + + # The id sent with extrusion_cali_sel has to name the preset the + # profile was calibrated under, and that preset can differ per + # printer model -- so it comes from the same cascade the assign + # paths use rather than straight off the spool. + model_filament, _ = await resolve_spool_preset( + db, + spool_id=spool.id, + printer_model=printer_manager.get_model(printer_id), + nozzle_diameter=nozzle_diameter, + fallback_filament=spool.slicer_filament, + fallback_name=spool.slicer_filament_name, + ) if matching_kp and matching_kp.cali_idx is not None: # The filament_id in extrusion_cali_sel must match the filament preset # under which the K-profile was calibrated. Use spool.slicer_filament # (the preset assigned in inventory), falling back to tray's RFID value. - cali_filament_id = spool.slicer_filament or tray_info_idx or "" + cali_filament_id = printer_safe_filament_id(model_filament, spool.slicer_filament, tray_info_idx) client.extrusion_cali_sel( ams_id=ams_id, tray_id=tray_id, @@ -610,7 +635,7 @@ async def auto_assign_spool( # so the printer keeps its existing calibration selection. live_cali_idx = tray.get("cali_idx") if live_cali_idx is not None and live_cali_idx >= 0: - cali_filament_id = spool.slicer_filament or tray_info_idx or "" + cali_filament_id = printer_safe_filament_id(model_filament, spool.slicer_filament, tray_info_idx) client.extrusion_cali_sel( ams_id=ams_id, tray_id=tray_id, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index c1f1feab7..6fe3ae068 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -244,6 +244,7 @@ async def test_engine(): spool, spool_assignment, spool_catalog, + spool_filament_preset, spool_k_profile, spool_usage_history, spoolbuddy_device, diff --git a/backend/tests/integration/test_slot_spool_defaults.py b/backend/tests/integration/test_slot_spool_defaults.py new file mode 100644 index 000000000..b3cdaa3c0 --- /dev/null +++ b/backend/tests/integration/test_slot_spool_defaults.py @@ -0,0 +1,168 @@ +"""GET /printers/{id}/slots/{ams}/{tray}/spool-defaults + +What the Configure AMS Slot dialog opens with. The slot usually already holds +an assigned spool, and that spool carries a filament preset per printer model +and a K profile per hotend -- the values the user set for exactly this +situation. Before this endpoint the dialog defaulted to the slot's last manual +configuration or the tray's RFID data and ignored them. + +Everything is resolved for the nozzle THIS slot feeds, so the answer differs +between the two hotends of a dual-nozzle machine. +""" + +from unittest.mock import MagicMock, patch + +import pytest +from httpx import AsyncClient + +URL = "/api/v1/printers/{pid}/slots/{ams}/{tray}/spool-defaults" + +RIGHT, LEFT = 0, 1 + + +class _Nozzle: + def __init__(self, diameter): + self.nozzle_diameter = diameter + + +class _State: + """Dual-nozzle, 0.4 on the right and 0.2 on the left, AMS 0 -> left.""" + + def __init__(self, diameters=("0.4", "0.2")): + self.nozzles = [_Nozzle(d) for d in diameters] + self.ams_extruder_map = {"0": LEFT, "1": RIGHT} + self.ams_switch_inlet = None + self.raw_data = {} + + +@pytest.fixture +def dual_nozzle_printer_state(): + with patch("backend.app.api.routes.printers.printer_manager") as manager: + manager.get_status = MagicMock(return_value=_State()) + manager.get_model = MagicMock(return_value="H2D") + yield manager + + +@pytest.fixture +async def assigned_spool(db_session, printer_factory): + """A spool in AMS 0 tray 0, with a per-model preset and both hotends calibrated.""" + from backend.app.models.spool import Spool + from backend.app.models.spool_assignment import SpoolAssignment + from backend.app.models.spool_filament_preset import SpoolFilamentPreset + from backend.app.models.spool_k_profile import SpoolKProfile + + printer = await printer_factory(model="H2D") + spool = Spool( + brand="Bambu", + material="PLA", + color_name="Black", + slicer_filament="GFSA00", + slicer_filament_name="Bambu PLA Basic @BBL X1C", + ) + db_session.add(spool) + await db_session.commit() + await db_session.refresh(spool) + + db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=printer.id, ams_id=0, tray_id=0)) + db_session.add( + SpoolFilamentPreset( + spool_id=spool.id, + printer_model="H2D", + nozzle_diameter="0.2", + slicer_filament="GFSA21", + slicer_filament_name="Bambu PLA Basic @BBL H2D 0.2 nozzle", + ) + ) + db_session.add_all( + [ + SpoolKProfile( + spool_id=spool.id, + printer_id=printer.id, + extruder=LEFT, + nozzle_diameter="0.2", + k_value=0.018, + cali_idx=16, + name="PLA left", + ), + SpoolKProfile( + spool_id=spool.id, + printer_id=printer.id, + extruder=RIGHT, + nozzle_diameter="0.4", + k_value=0.020, + cali_idx=15, + name="PLA right", + ), + ] + ) + await db_session.commit() + return printer, spool + + +@pytest.mark.integration +class TestSlotSpoolDefaults: + @pytest.mark.asyncio + async def test_answers_for_the_hotend_this_slot_feeds( + self, async_client: AsyncClient, assigned_spool, dual_nozzle_printer_state + ): + printer, _ = assigned_spool + response = await async_client.get(URL.format(pid=printer.id, ams=0, tray=0)) + assert response.status_code == 200, response.text + body = response.json() + + # AMS 0 feeds the LEFT hotend, which has the 0.2 fitted. + assert body["extruder"] == LEFT + assert body["nozzle_diameter"] == "0.2" + # So the 0.2 preset override, not the spool's own X1C one... + assert body["slicer_filament"] == "GFSA21" + # ...and the profile calibrated on that hotend, not the other's. + assert body["cali_idx"] == 16 + assert body["k_value"] == pytest.approx(0.018) + assert body["profile_name"] == "PLA left" + + @pytest.mark.asyncio + async def test_a_slot_with_no_spool_answers_nulls_not_404( + self, async_client: AsyncClient, assigned_spool, dual_nozzle_printer_state + ): + """ "Nothing configured" is an ordinary answer -- the dialog falls back + to what it did before rather than treating it as an error.""" + printer, _ = assigned_spool + response = await async_client.get(URL.format(pid=printer.id, ams=1, tray=3)) + assert response.status_code == 200 + body = response.json() + assert body["slicer_filament"] is None + assert body["cali_idx"] is None + # The nozzle is still resolved -- AMS 1 is the right hotend. + assert body["extruder"] == RIGHT + assert body["nozzle_diameter"] == "0.4" + + @pytest.mark.asyncio + async def test_falls_back_to_the_spools_own_preset_without_an_override( + self, async_client: AsyncClient, assigned_spool, dual_nozzle_printer_state, db_session + ): + from backend.app.models.spool_assignment import SpoolAssignment + + printer, spool = assigned_spool + # Same spool in a slot on the RIGHT hotend, which has no 0.4 override. + db_session.add(SpoolAssignment(spool_id=spool.id, printer_id=printer.id, ams_id=1, tray_id=0)) + await db_session.commit() + + body = (await async_client.get(URL.format(pid=printer.id, ams=1, tray=0))).json() + + assert body["slicer_filament"] == "GFSA00" + assert body["cali_idx"] == 15 + + @pytest.mark.asyncio + async def test_an_offline_printer_still_answers(self, async_client: AsyncClient, assigned_spool): + """Opening the dialog on a disconnected printer must not 500 -- it just + cannot say which hotend the slot feeds.""" + printer, _ = assigned_spool + with patch("backend.app.api.routes.printers.printer_manager") as manager: + manager.get_status = MagicMock(return_value=None) + manager.get_model = MagicMock(return_value="H2D") + response = await async_client.get(URL.format(pid=printer.id, ams=0, tray=0)) + + assert response.status_code == 200 + body = response.json() + assert body["extruder"] is None + assert body["nozzle_diameter"] == "0.4" diff --git a/backend/tests/integration/test_spool_filament_preset_endpoints.py b/backend/tests/integration/test_spool_filament_preset_endpoints.py new file mode 100644 index 000000000..056178ab6 --- /dev/null +++ b/backend/tests/integration/test_spool_filament_preset_endpoints.py @@ -0,0 +1,234 @@ +"""Endpoints for the per-printer-model filament preset overrides. + + GET /api/v1/inventory/spools/{id}/filament-presets + PUT /api/v1/inventory/spools/{id}/filament-presets + GET /api/v1/spoolman/inventory/spools/{id}/filament-presets + PUT /api/v1/spoolman/inventory/spools/{id}/filament-presets + +Both PUTs replace the whole set, matching the K-profile endpoints beside them: +the spool form always holds the complete list, and an empty body is how the +user clears every override back to the spool's own preset. + +The case worth having a test for is the duplicate: (model, diameter) is +UNIQUE, so a payload naming one twice has to be refused -- and refused +*before* the existing rows are deleted, or a rejected save takes the user's +overrides with it. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from httpx import AsyncClient + +SAMPLE_SPOOL = { + "id": 7, + "filament": { + "id": 1, + "name": "PLA Basic", + "material": "PLA", + "weight": 1000, + "color_hex": "303030", + "vendor": {"id": 1, "name": "Bambu"}, + }, + "remaining_weight": 800.0, + "used_weight": 200.0, + "location": None, + "comment": None, + "first_used": None, + "last_used": None, + "registered": "2024-01-01T00:00:00+00:00", + "archived": False, + "price": None, + "extra": {}, +} + +INTERNAL = "/api/v1/inventory/spools" +SPOOLMAN = "/api/v1/spoolman/inventory/spools" + + +@pytest.fixture +async def spool(db_session): + from backend.app.models.spool import Spool + + row = Spool( + brand="Bambu", + material="PLA", + color_name="Charcoal", + slicer_filament="GFSA00", + slicer_filament_name="Bambu PLA Basic @BBL X1C", + ) + db_session.add(row) + await db_session.commit() + await db_session.refresh(row) + return row + + +@pytest.fixture +async def spoolman_settings(db_session): + from backend.app.models.settings import Settings + + db_session.add(Settings(key="spoolman_enabled", value="true")) + db_session.add(Settings(key="spoolman_url", value="http://localhost:7912")) + await db_session.commit() + + +@pytest.fixture +def mock_spoolman_client(): + client = MagicMock() + client.base_url = "http://localhost:7912" + client.health_check = AsyncMock(return_value=True) + client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL) + + with patch( + "backend.app.api.routes.spoolman_inventory._get_client", + AsyncMock(return_value=client), + ): + yield client + + +def _preset(model, diameter="", code="GFSA09", name="Bambu PLA Basic @BBL H2C"): + return { + "printer_model": model, + "nozzle_diameter": diameter, + "slicer_filament": code, + "slicer_filament_name": name, + } + + +@pytest.mark.integration +class TestInternalInventory: + @pytest.mark.asyncio + async def test_empty_by_default(self, async_client: AsyncClient, spool): + response = await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets") + assert response.status_code == 200 + assert response.json() == [] + + @pytest.mark.asyncio + async def test_put_then_get_round_trips(self, async_client: AsyncClient, spool): + response = await async_client.put( + f"{INTERNAL}/{spool.id}/filament-presets", + json=[_preset("H2C"), _preset("A1 mini", "0.2", "GFSA21", "PLA @A1M 0.2 nozzle")], + ) + assert response.status_code == 200, response.text + + rows = (await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")).json() + assert len(rows) == 2 + by_model = {r["printer_model"]: r for r in rows} + assert by_model["H2C"]["nozzle_diameter"] == "" + assert by_model["H2C"]["slicer_filament"] == "GFSA09" + assert by_model["A1 mini"]["nozzle_diameter"] == "0.2" + assert by_model["A1 mini"]["slicer_filament"] == "GFSA21" + assert all(r["spool_id"] == spool.id for r in rows) + + @pytest.mark.asyncio + async def test_put_replaces_rather_than_appends(self, async_client: AsyncClient, spool): + await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("H2C")]) + await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("X1C")]) + + rows = (await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")).json() + assert [r["printer_model"] for r in rows] == ["X1C"] + + @pytest.mark.asyncio + async def test_empty_body_clears_every_override(self, async_client: AsyncClient, spool): + await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("H2C")]) + + response = await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[]) + assert response.status_code == 200 + assert (await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")).json() == [] + + @pytest.mark.asyncio + async def test_replacing_the_same_key_does_not_trip_the_unique_constraint(self, async_client: AsyncClient, spool): + """Deletes and inserts land in one transaction, and SQLAlchemy is free + to order the INSERTs first. Re-saving the same (model, diameter) with a + new preset is the ordinary case -- the user changed their pick.""" + await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("H2C")]) + + response = await async_client.put( + f"{INTERNAL}/{spool.id}/filament-presets", + json=[_preset("H2C", "", "GFSA11", "Bambu PLA Matte @BBL H2C")], + ) + assert response.status_code == 200, response.text + + rows = (await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")).json() + assert len(rows) == 1 + assert rows[0]["slicer_filament"] == "GFSA11" + + @pytest.mark.asyncio + async def test_duplicate_key_is_rejected_without_losing_the_stored_rows(self, async_client: AsyncClient, spool): + await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("H2C")]) + + response = await async_client.put( + f"{INTERNAL}/{spool.id}/filament-presets", + json=[_preset("X1C", "", "GFSA01"), _preset("X1C", "", "GFSA02")], + ) + assert response.status_code == 422 + + # The rejected save must not have taken the existing override with it. + rows = (await async_client.get(f"{INTERNAL}/{spool.id}/filament-presets")).json() + assert [r["printer_model"] for r in rows] == ["H2C"] + + @pytest.mark.asyncio + async def test_unknown_spool_is_404(self, async_client: AsyncClient): + response = await async_client.put(f"{INTERNAL}/999999/filament-presets", json=[_preset("H2C")]) + assert response.status_code == 404 + + @pytest.mark.asyncio + async def test_blank_model_is_rejected(self, async_client: AsyncClient, spool): + """An empty printer_model would store a row the cascade can never + match, since it refuses to resolve without a model.""" + response = await async_client.put(f"{INTERNAL}/{spool.id}/filament-presets", json=[_preset("")]) + assert response.status_code == 422 + + +@pytest.mark.integration +class TestSpoolmanInventory: + @pytest.mark.asyncio + async def test_empty_by_default(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client): + response = await async_client.get(f"{SPOOLMAN}/7/filament-presets") + assert response.status_code == 200 + assert response.json() == [] + + @pytest.mark.asyncio + async def test_put_then_get_round_trips(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client): + response = await async_client.put( + f"{SPOOLMAN}/7/filament-presets", + json=[_preset("H2C"), _preset("H2C", "0.2", "GFSA10", "PLA @H2C 0.2 nozzle")], + ) + assert response.status_code == 200, response.text + + rows = (await async_client.get(f"{SPOOLMAN}/7/filament-presets")).json() + assert len(rows) == 2 + assert all(r["spool_id"] == 7 for r in rows) + assert {r["nozzle_diameter"] for r in rows} == {"", "0.2"} + + @pytest.mark.asyncio + async def test_put_replaces_rather_than_appends( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + ): + await async_client.put(f"{SPOOLMAN}/7/filament-presets", json=[_preset("H2C")]) + await async_client.put(f"{SPOOLMAN}/7/filament-presets", json=[_preset("X1C")]) + + rows = (await async_client.get(f"{SPOOLMAN}/7/filament-presets")).json() + assert [r["printer_model"] for r in rows] == ["X1C"] + + @pytest.mark.asyncio + async def test_duplicate_key_is_rejected_without_losing_the_stored_rows( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + ): + await async_client.put(f"{SPOOLMAN}/7/filament-presets", json=[_preset("H2C")]) + + response = await async_client.put( + f"{SPOOLMAN}/7/filament-presets", + json=[_preset("X1C", "", "GFSA01"), _preset("X1C", "", "GFSA02")], + ) + assert response.status_code == 422 + + rows = (await async_client.get(f"{SPOOLMAN}/7/filament-presets")).json() + assert [r["printer_model"] for r in rows] == ["H2C"] + + @pytest.mark.asyncio + async def test_one_spools_overrides_do_not_leak_into_another( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + ): + await async_client.put(f"{SPOOLMAN}/7/filament-presets", json=[_preset("H2C")]) + assert (await async_client.get(f"{SPOOLMAN}/8/filament-presets")).json() == [] diff --git a/backend/tests/unit/test_assign_uses_model_preset_override.py b/backend/tests/unit/test_assign_uses_model_preset_override.py new file mode 100644 index 000000000..5ab4a5e88 --- /dev/null +++ b/backend/tests/unit/test_assign_uses_model_preset_override.py @@ -0,0 +1,179 @@ +"""The per-model override has to reach the slot, not just the database. + +``apply_spool_to_slot_via_mqtt`` is where a spool becomes an AMS slot +configuration, and the preset it resolves is what the printer (and the slicer +reading the slot back) ends up with. These drive that function and assert on +what it handed to ``resolve_slicer_filament``, which is the last point the +preset is still a stored reference rather than a printer-side id. + +The regression they guard is the whole point of the feature: a spool whose +single ``slicer_filament`` is an ``@BBL X1C`` preset, assigned on an H2C, +previously configured that H2C slot with the X1C preset. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from backend.app.models.spool import Spool +from backend.app.models.spool_filament_preset import SpoolFilamentPreset + +pytestmark = pytest.mark.asyncio + +SPOOL_DEFAULT = "GFSA00" +SPOOL_DEFAULT_NAME = "Bambu PLA Basic @BBL X1C" + + +class _Nozzle: + def __init__(self, diameter: str): + self.nozzle_diameter = diameter + self.nozzle_type = "HH01" + + +class _State: + """Just enough live printer state for the assign path.""" + + def __init__(self, diameter: str = "0.4"): + self.nozzles = [_Nozzle(diameter), _Nozzle(diameter)] + self.ams_extruder_map = {} + self.kprofiles = [] + self.raw_data = {} + + +async def _spool(db_session) -> Spool: + spool = Spool( + brand="Bambu", + material="PLA", + color_name="Charcoal", + slicer_filament=SPOOL_DEFAULT, + slicer_filament_name=SPOOL_DEFAULT_NAME, + ) + db_session.add(spool) + await db_session.commit() + # Loaded the way every production caller loads it: k_profiles is a lazy + # relationship the assign path walks, and an async session cannot resolve + # it mid-call. + result = await db_session.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id)) + return result.scalar_one() + + +async def _assign(db_session, spool, printer, *, model: str, diameter: str = "0.4"): + """Run the assign path far enough to capture the resolved preset. + + Returns the kwargs ``resolve_slicer_filament`` was called with. Everything + downstream of it is stubbed: this asserts which preset was chosen, not how + the MQTT payload is built (that is covered elsewhere). + """ + from backend.app.api.routes import inventory as inventory_module + + resolver = AsyncMock(return_value=("GFL99", "GFSL99", "", "")) + + manager = MagicMock() + manager.get_client = MagicMock(return_value=MagicMock()) + manager.get_status = MagicMock(return_value=_State(diameter)) + manager.get_model = MagicMock(return_value=model) + + with ( + patch.object(inventory_module, "resolve_slicer_filament", resolver), + patch("backend.app.services.printer_manager.printer_manager", manager), + ): + await inventory_module.apply_spool_to_slot_via_mqtt( + db=db_session, + current_user=None, + spool=spool, + printer_id=printer.id, + ams_id=0, + tray_id=0, + ) + + assert resolver.await_count == 1, "the assign path did not reach the preset resolver" + return resolver.await_args.kwargs + + +class TestWithoutAnOverride: + async def test_the_spools_own_preset_is_used(self, db_session, printer_factory): + """Every spool in every existing install. Behaviour must be unchanged.""" + printer = await printer_factory(model="H2C") + spool = await _spool(db_session) + + kwargs = await _assign(db_session, spool, printer, model="H2C") + + assert kwargs["slicer_filament"] == SPOOL_DEFAULT + assert kwargs["slicer_filament_name"] == SPOOL_DEFAULT_NAME + + +class TestWithAModelOverride: + async def test_the_override_replaces_the_spools_preset(self, db_session, printer_factory): + printer = await printer_factory(model="H2C") + spool = await _spool(db_session) + db_session.add( + SpoolFilamentPreset( + spool_id=spool.id, + printer_model="H2C", + nozzle_diameter="", + slicer_filament="GFSA09", + slicer_filament_name="Bambu PLA Basic @BBL H2C", + ) + ) + await db_session.commit() + + kwargs = await _assign(db_session, spool, printer, model="H2C") + + assert kwargs["slicer_filament"] == "GFSA09" + assert kwargs["slicer_filament_name"] == "Bambu PLA Basic @BBL H2C" + + async def test_a_different_model_still_gets_the_spools_preset(self, db_session, printer_factory): + """An override for the H2C must not follow the spool onto the X1C.""" + printer = await printer_factory(model="X1C") + spool = await _spool(db_session) + db_session.add( + SpoolFilamentPreset( + spool_id=spool.id, + printer_model="H2C", + nozzle_diameter="", + slicer_filament="GFSA09", + slicer_filament_name="Bambu PLA Basic @BBL H2C", + ) + ) + await db_session.commit() + + kwargs = await _assign(db_session, spool, printer, model="X1C") + + assert kwargs["slicer_filament"] == SPOOL_DEFAULT + + +class TestPerDiameterOverride: + async def test_the_slots_nozzle_diameter_selects_the_preset(self, db_session, printer_factory): + """The diameter comes from live printer state, so the same spool on + the same model resolves differently once a 0.2 nozzle is fitted.""" + printer = await printer_factory(model="A1 mini") + spool = await _spool(db_session) + db_session.add_all( + [ + SpoolFilamentPreset( + spool_id=spool.id, + printer_model="A1 mini", + nozzle_diameter="", + slicer_filament="GFSA20", + slicer_filament_name="Bambu PLA Basic @BBL A1M", + ), + SpoolFilamentPreset( + spool_id=spool.id, + printer_model="A1 mini", + nozzle_diameter="0.2", + slicer_filament="GFSA21", + slicer_filament_name="Bambu PLA Basic @BBL A1M 0.2 nozzle", + ), + ] + ) + await db_session.commit() + + on_04 = await _assign(db_session, spool, printer, model="A1 mini", diameter="0.4") + on_02 = await _assign(db_session, spool, printer, model="A1 mini", diameter="0.2") + + assert on_04["slicer_filament"] == "GFSA20" + assert on_02["slicer_filament"] == "GFSA21" diff --git a/backend/tests/unit/test_rfid_assign_picks_the_right_hotend.py b/backend/tests/unit/test_rfid_assign_picks_the_right_hotend.py new file mode 100644 index 000000000..6eba26224 --- /dev/null +++ b/backend/tests/unit/test_rfid_assign_picks_the_right_hotend.py @@ -0,0 +1,245 @@ +"""RFID auto-assign has to pick the K profile for the hotend the slot feeds. + +``auto_assign_spool`` runs unattended every time a Bambu spool is detected in a +slot. It sends no ``ams_filament_setting`` -- the firmware already has the +filament from the tag, and overwriting it turns the eye icon into a pen in +Studio -- but it does select a K profile with ``extrusion_cali_sel``. + +It used to take the first stored row matching (printer, nozzle diameter) with +no extruder test at all. On a dual-nozzle printer a spool calibrated on both +hotends therefore had a coin toss decide which K value the slot got, and the +losing side prints with the other nozzle's pressure advance. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from sqlalchemy.orm import selectinload + +from backend.app.models.spool import Spool +from backend.app.models.spool_k_profile import SpoolKProfile +from backend.app.services.spool_tag_matcher import auto_assign_spool + +pytestmark = pytest.mark.asyncio + +RIGHT, LEFT = 0, 1 + + +class _Nozzle: + def __init__(self, diameter): + self.nozzle_diameter = diameter + + +class _State: + """Dual-nozzle printer, AMS 0 on the left hotend and AMS 1 on the right.""" + + def __init__(self, diameters=("0.4", "0.4")): + self.nozzles = [_Nozzle(d) for d in diameters] + self.ams_extruder_map = {"0": LEFT, "1": RIGHT} + self.ams_switch_inlet = None + self.raw_data = {} + + +def _manager(state, client): + manager = MagicMock() + manager.get_status = MagicMock(return_value=state) + manager.get_client = MagicMock(return_value=client) + manager.get_model = MagicMock(return_value="H2D") + return manager + + +async def _spool_with_both_hotends(engine, printer_id, diameters=("0.4", "0.4")): + maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with maker() as db: + spool = Spool(brand="Bambu", material="PLA", color_name="Black", slicer_filament="GFSA00") + db.add(spool) + await db.commit() + await db.refresh(spool) + + # Same spool, calibrated on both hotends -- the maintainer's H2C reads + # 0.018 left and 0.020 right for one black PLA. + db.add_all( + [ + SpoolKProfile( + spool_id=spool.id, + printer_id=printer_id, + extruder=RIGHT, + nozzle_diameter=diameters[RIGHT], + k_value=0.020, + cali_idx=15, + ), + SpoolKProfile( + spool_id=spool.id, + printer_id=printer_id, + extruder=LEFT, + nozzle_diameter=diameters[LEFT], + k_value=0.018, + cali_idx=16, + ), + ] + ) + await db.commit() + return maker, spool.id + + +async def _load(maker, spool_id) -> Spool: + async with maker() as db: + result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id)) + return result.scalar_one() + + +async def _assign(maker, spool, printer, ams_id, state): + client = MagicMock() + async with maker() as db: + await auto_assign_spool(printer.id, ams_id, 0, spool, _manager(state, client), db, tray_info_idx="GFA00") + return client + + +class TestWhichHotendsProfileIsSelected: + async def test_a_slot_on_the_left_gets_the_left_profile(self, test_engine, printer_factory): + printer = await printer_factory(model="H2D") + maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id) + spool = await _load(maker, spool_id) + + client = await _assign(maker, spool, printer, ams_id=0, state=_State()) + + client.extrusion_cali_sel.assert_called_once() + assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 16 + + async def test_a_slot_on_the_right_gets_the_right_profile(self, test_engine, printer_factory): + printer = await printer_factory(model="H2D") + maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id) + spool = await _load(maker, spool_id) + + client = await _assign(maker, spool, printer, ams_id=1, state=_State()) + + assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 15 + + async def test_the_slots_own_nozzle_size_decides_the_diameter(self, test_engine, printer_factory): + """0.4 right, 0.2 left: the left slot must look up 0.2 profiles, which + is what reading nozzles[0] for every slot got wrong.""" + printer = await printer_factory(model="H2D") + maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id, diameters=("0.4", "0.2")) + spool = await _load(maker, spool_id) + + client = await _assign(maker, spool, printer, ams_id=0, state=_State(("0.4", "0.2"))) + + kwargs = client.extrusion_cali_sel.call_args.kwargs + assert kwargs["nozzle_diameter"] == "0.2" + assert kwargs["cali_idx"] == 16 + + +class TestTheFilamentIdSentWithTheSelection: + """extrusion_cali_sel carries a filament id so the printer can link the + calibration index to the slot. A cloud USER preset id (PFUS/PFCN) is not one + the slicer accepts -- and a per-model override can now BE such an id, since + picking your own cloud preset for a model stores exactly that.""" + + async def test_a_cloud_user_preset_override_is_not_sent_to_the_printer(self, test_engine, printer_factory): + from backend.app.models.spool_filament_preset import SpoolFilamentPreset + + printer = await printer_factory(model="H2D") + maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id) + async with maker() as db: + db.add( + SpoolFilamentPreset( + spool_id=spool_id, + printer_model="H2D", + nozzle_diameter="0.4", + slicer_filament="PFUS279c9bd2c689d5", + slicer_filament_name="# Bambu PETG HF @BBL H2D 0.4 nozzle", + ) + ) + await db.commit() + spool = await _load(maker, spool_id) + + client = await _assign(maker, spool, printer, ams_id=0, state=_State()) + + # Falls through to the spool's own preset rather than sending a value + # the printer rejects, which would silently lose the K-profile link. + assert client.extrusion_cali_sel.call_args.kwargs["filament_id"] == "GFSA00" + + async def test_a_normal_override_is_sent(self, test_engine, printer_factory): + from backend.app.models.spool_filament_preset import SpoolFilamentPreset + + printer = await printer_factory(model="H2D") + maker, spool_id = await _spool_with_both_hotends(test_engine, printer.id) + async with maker() as db: + db.add( + SpoolFilamentPreset( + spool_id=spool_id, + printer_model="H2D", + nozzle_diameter="0.4", + slicer_filament="GFSG02_15", + slicer_filament_name="Bambu PETG HF @BBL H2D", + ) + ) + await db.commit() + spool = await _load(maker, spool_id) + + client = await _assign(maker, spool, printer, ams_id=0, state=_State()) + + assert client.extrusion_cali_sel.call_args.kwargs["filament_id"] == "GFSG02_15" + + +class TestFallbacks: + async def test_a_profile_for_the_other_hotend_is_better_than_none(self, test_engine, printer_factory): + """An operator who calibrated one side only should still get that + profile rather than nothing -- the fallback the old code had by + accident, kept deliberately.""" + printer = await printer_factory(model="H2D") + maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + async with maker() as db: + spool = Spool(brand="Bambu", material="PLA", color_name="Black") + db.add(spool) + await db.commit() + await db.refresh(spool) + db.add( + SpoolKProfile( + spool_id=spool.id, + printer_id=printer.id, + extruder=RIGHT, + nozzle_diameter="0.4", + k_value=0.020, + cali_idx=15, + ) + ) + await db.commit() + loaded = await _load(maker, spool.id) + + client = await _assign(maker, loaded, printer, ams_id=0, state=_State()) + + assert client.extrusion_cali_sel.call_args.kwargs["cali_idx"] == 15 + + async def test_a_profile_for_a_different_nozzle_size_is_not_used(self, test_engine, printer_factory): + """Diameter is not negotiable the way the hotend is: a K value measured + on a 0.6 says nothing about a 0.4.""" + printer = await printer_factory(model="H2D") + maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + async with maker() as db: + spool = Spool(brand="Bambu", material="PLA", color_name="Black") + db.add(spool) + await db.commit() + await db.refresh(spool) + db.add( + SpoolKProfile( + spool_id=spool.id, + printer_id=printer.id, + extruder=LEFT, + nozzle_diameter="0.6", + k_value=0.030, + cali_idx=20, + ) + ) + await db.commit() + loaded = await _load(maker, spool.id) + + client = await _assign(maker, loaded, printer, ams_id=0, state=_State()) + + # No stored profile for 0.4 -- nothing is selected from the store. + selected = [c for c in client.extrusion_cali_sel.call_args_list if c.kwargs.get("cali_idx") == 20] + assert selected == [] diff --git a/backend/tests/unit/test_slot_nozzle_resolution.py b/backend/tests/unit/test_slot_nozzle_resolution.py new file mode 100644 index 000000000..6b60a30d0 --- /dev/null +++ b/backend/tests/unit/test_slot_nozzle_resolution.py @@ -0,0 +1,120 @@ +"""Which nozzle an AMS slot feeds. + +Every path that configures a slot needs the extruder and that nozzle's +diameter, and both the filament preset and the K profile are stored per +diameter -- so a wrong answer here silently selects the wrong preset AND the +wrong K value. Seven call sites used to work it out independently, each reading +``nozzles[0]`` for every slot on the machine. + +``nozzles[0]`` is right on a single-nozzle printer, and right on a dual-nozzle +printer with matching nozzles, which is why it survived. These pin the case it +is wrong for: two different sizes fitted, which is the machine the per-nozzle +feature exists for. +""" + +from __future__ import annotations + +from backend.app.services.slot_nozzle import ( + DEFAULT_NOZZLE_DIAMETER, + nozzle_diameter_for_extruder, + resolve_slot_nozzle, +) + + +class _Nozzle: + def __init__(self, diameter: str): + self.nozzle_diameter = diameter + + +class _State: + def __init__(self, diameters, ams_extruder_map=None, ams_switch_inlet=None): + self.nozzles = [_Nozzle(d) for d in diameters] + self.ams_extruder_map = ams_extruder_map + self.ams_switch_inlet = ams_switch_inlet + + +# extruder 0 is the RIGHT hotend, 1 the left. +MIXED = ["0.4", "0.2"] + + +class TestDualNozzleMixedDiameters: + """The case the old shortcut got wrong.""" + + def test_each_hotend_reports_its_own_diameter(self): + state = _State(MIXED) + assert nozzle_diameter_for_extruder(state, 0, "H2C") == "0.4" + assert nozzle_diameter_for_extruder(state, 1, "H2C") == "0.2" + + def test_the_slots_extruder_decides_which_one(self): + # AMS 0 is bound to the left hotend, AMS 1 to the right. + state = _State(MIXED, ams_extruder_map={"0": 1, "1": 0}) + + left = resolve_slot_nozzle(state, 0, 0, "H2C") + right = resolve_slot_nozzle(state, 1, 0, "H2C") + assert (left.extruder, left.diameter) == (1, "0.2") + assert (right.extruder, right.diameter) == (0, "0.4") + + def test_external_slots_name_their_side_by_tray_id(self): + # Ext-L is tray 0 -> extruder 1, Ext-R is tray 1 -> extruder 0. + state = _State(MIXED) + assert resolve_slot_nozzle(state, 255, 0, "H2D").diameter == "0.2" + assert resolve_slot_nozzle(state, 255, 1, "H2D").diameter == "0.4" + + def test_an_fts_inlet_answers_when_there_is_no_extruder_map(self): + state = _State(MIXED, ams_switch_inlet={"0": "A"}) + resolved = resolve_slot_nozzle(state, 0, 0, "H2C") + assert resolved.extruder is not None + assert resolved.diameter in {"0.4", "0.2"} + + +class TestSingleNozzle: + """Must behave exactly as the old shortcut did.""" + + def test_index_zero_whatever_the_map_says(self): + # A single-nozzle model has one entry; an extruder id from a stale map + # must not index past it. + state = _State(["0.6"], ams_extruder_map={"0": 1}) + assert nozzle_diameter_for_extruder(state, 1, "X1C") == "0.6" + assert resolve_slot_nozzle(state, 0, 0, "X1C").diameter == "0.6" + + def test_no_map_means_unknown_extruder_not_zero(self): + # "I don't know" and "the right-hand nozzle" are different answers; the + # caller's own default of 0 is correct on a single-nozzle machine, but + # storing it as a fact is what bound a left K-profile to a right slot. + state = _State(["0.4"]) + resolved = resolve_slot_nozzle(state, 0, 0, "X1C") + assert resolved.extruder is None + assert resolved.extruder_or_default == 0 + + +class TestMissingHardware: + """Called on every assign, so it must never raise.""" + + def test_no_state_at_all(self): + resolved = resolve_slot_nozzle(None, 0, 0, "H2C") + assert (resolved.extruder, resolved.diameter) == (None, DEFAULT_NOZZLE_DIAMETER) + + def test_printer_has_reported_no_nozzles(self): + assert nozzle_diameter_for_extruder(_State([]), 0, "H2C") == DEFAULT_NOZZLE_DIAMETER + + def test_the_second_hotend_is_absent_from_the_report(self): + # H2C parks a nozzle back in its rack and the entry can go missing; + # falling back to the other hotend beats returning nothing. + assert nozzle_diameter_for_extruder(_State(["0.4"]), 1, "H2C") == "0.4" + + def test_a_blank_diameter_is_not_a_diameter(self): + # NozzleInfo starts life with an empty string before MQTT fills it in. + # Falling back to the OTHER hotend's size would invent a fact about a + # different nozzle, so a blank primary takes the default instead -- + # which is what every call site did before this module existed. + assert nozzle_diameter_for_extruder(_State(["", "0.6"]), 0, "H2C") == DEFAULT_NOZZLE_DIAMETER + assert nozzle_diameter_for_extruder(_State(["", ""]), 0, "H2C") == DEFAULT_NOZZLE_DIAMETER + + +class TestMatchingNozzles: + """The common fleet: both hotends the same size.""" + + def test_both_conventions_agree_so_the_answer_cannot_be_wrong(self): + state = _State(["0.4", "0.4"], ams_extruder_map={"0": 1}) + assert nozzle_diameter_for_extruder(state, 0, "H2C") == "0.4" + assert nozzle_diameter_for_extruder(state, 1, "H2C") == "0.4" diff --git a/backend/tests/unit/test_spool_filament_preset_cascade.py b/backend/tests/unit/test_spool_filament_preset_cascade.py new file mode 100644 index 000000000..226662da9 --- /dev/null +++ b/backend/tests/unit/test_spool_filament_preset_cascade.py @@ -0,0 +1,206 @@ +"""The per-printer-model preset cascade. + +``Spool.slicer_filament`` holds one preset and is printer-agnostic by design. +That breaks as soon as a spool is used on two printer models, because a cloud +or Orca preset is bound to a model (``@BBL X1C``): the AMS slot on an H2C gets +configured with a preset that machine has no profile for. + +``services.spool_filament_preset`` resolves, most specific first: + + (model, diameter) -> (model, "") -> the spool's own slicer_filament + +These pin the resolution order itself, including the two cases that are easy +to get backwards: a spool with no overrides must behave exactly as it did +before the feature existed, and a stored row whose preset is blank is the +user clearing the preset for that model, not a hole to fall through. +""" + +from __future__ import annotations + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from backend.app.models.spool import Spool +from backend.app.models.spool_filament_preset import SpoolFilamentPreset, SpoolmanFilamentPreset +from backend.app.services.spool_filament_preset import resolve_spool_preset, resolve_spoolman_preset + +pytestmark = pytest.mark.asyncio + +DEFAULT = ("GFSA00", "Bambu PLA Basic @BBL X1C") + + +async def _spool(engine) -> tuple[async_sessionmaker, int]: + maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with maker() as db: + spool = Spool( + brand="Bambu", + material="PLA", + color_name="Charcoal", + slicer_filament=DEFAULT[0], + slicer_filament_name=DEFAULT[1], + ) + db.add(spool) + await db.commit() + await db.refresh(spool) + return maker, spool.id + + +async def _resolve(maker, spool_id, model, diameter): + async with maker() as db: + return await resolve_spool_preset( + db, + spool_id=spool_id, + printer_model=model, + nozzle_diameter=diameter, + fallback_filament=DEFAULT[0], + fallback_name=DEFAULT[1], + ) + + +async def _add(maker, spool_id, model, diameter, code, name): + async with maker() as db: + db.add( + SpoolFilamentPreset( + spool_id=spool_id, + printer_model=model, + nozzle_diameter=diameter, + slicer_filament=code, + slicer_filament_name=name, + ) + ) + await db.commit() + + +class TestNoOverrides: + """Every spool in every existing install is this case.""" + + async def test_falls_back_to_the_spools_own_preset(self, test_engine): + maker, spool_id = await _spool(test_engine) + assert await _resolve(maker, spool_id, "H2C", "0.4") == DEFAULT + + async def test_an_unknown_model_falls_back(self, test_engine): + maker, spool_id = await _spool(test_engine) + await _add(maker, spool_id, "X1C", "", "GFSA01", "PLA @X1C") + assert await _resolve(maker, spool_id, "P1S", "0.4") == DEFAULT + + async def test_no_model_at_all_falls_back(self, test_engine): + """A printer that has not reported its model yet cannot be more + specific than the spool itself -- it must not match some other row.""" + maker, spool_id = await _spool(test_engine) + await _add(maker, spool_id, "X1C", "", "GFSA01", "PLA @X1C") + assert await _resolve(maker, spool_id, None, "0.4") == DEFAULT + assert await _resolve(maker, spool_id, "", "0.4") == DEFAULT + + +class TestModelDefault: + """Diameter "" = any nozzle of the model. + + The spool form writes one row per nozzle size and never this one, but the + API accepts it and it has to keep resolving -- these pin that level of the + cascade so a client using it does not break silently. + """ + + async def test_model_row_wins_over_the_spool_default(self, test_engine): + maker, spool_id = await _spool(test_engine) + await _add(maker, spool_id, "H2C", "", "GFSA09", "Bambu PLA Basic @BBL H2C") + assert await _resolve(maker, spool_id, "H2C", "0.4") == ("GFSA09", "Bambu PLA Basic @BBL H2C") + + async def test_it_applies_to_every_nozzle_of_that_model(self, test_engine): + maker, spool_id = await _spool(test_engine) + await _add(maker, spool_id, "H2C", "", "GFSA09", "Bambu PLA Basic @BBL H2C") + for diameter in ("0.2", "0.4", "0.6", "0.8", ""): + assert (await _resolve(maker, spool_id, "H2C", diameter))[0] == "GFSA09", diameter + + async def test_other_models_are_untouched(self, test_engine): + maker, spool_id = await _spool(test_engine) + await _add(maker, spool_id, "H2C", "", "GFSA09", "Bambu PLA Basic @BBL H2C") + await _add(maker, spool_id, "A1 mini", "", "GFSA20", "Bambu PLA Basic @BBL A1M") + assert (await _resolve(maker, spool_id, "H2C", "0.4"))[0] == "GFSA09" + assert (await _resolve(maker, spool_id, "A1 mini", "0.4"))[0] == "GFSA20" + assert (await _resolve(maker, spool_id, "X1C", "0.4"))[0] == DEFAULT[0] + + +class TestPerDiameterException: + """Why diameter is in the key at all: the preset lands on an AMS slot and + a slot feeds exactly one nozzle, so a machine with two diameters fitted + needs two answers for one model.""" + + async def test_exact_diameter_beats_the_model_default(self, test_engine): + maker, spool_id = await _spool(test_engine) + await _add(maker, spool_id, "A1 mini", "", "GFSA20", "Bambu PLA Basic @BBL A1M") + await _add(maker, spool_id, "A1 mini", "0.2", "GFSA21", "Bambu PLA Basic @BBL A1M 0.2 nozzle") + + assert (await _resolve(maker, spool_id, "A1 mini", "0.2"))[0] == "GFSA21" + assert (await _resolve(maker, spool_id, "A1 mini", "0.4"))[0] == "GFSA20" + + async def test_a_diameter_row_alone_still_leaves_other_nozzles_on_the_default(self, test_engine): + maker, spool_id = await _spool(test_engine) + await _add(maker, spool_id, "A1 mini", "0.2", "GFSA21", "Bambu PLA Basic @BBL A1M 0.2 nozzle") + + assert (await _resolve(maker, spool_id, "A1 mini", "0.2"))[0] == "GFSA21" + assert (await _resolve(maker, spool_id, "A1 mini", "0.4"))[0] == DEFAULT[0] + + async def test_the_two_hotends_of_one_machine_resolve_differently(self, test_engine): + """The case that put diameter in the key: 0.4 on one hotend, 0.2 on + the other, one model, one spool.""" + maker, spool_id = await _spool(test_engine) + await _add(maker, spool_id, "H2C", "0.4", "GFSA09", "Bambu PLA Basic @BBL H2C") + await _add(maker, spool_id, "H2C", "0.2", "GFSA10", "Bambu PLA Basic @BBL H2C 0.2 nozzle") + + assert (await _resolve(maker, spool_id, "H2C", "0.4"))[0] == "GFSA09" + assert (await _resolve(maker, spool_id, "H2C", "0.2"))[0] == "GFSA10" + + +class TestClearedPreset: + async def test_a_blank_row_means_none_not_fall_through(self, test_engine): + """The user picking the empty entry for a model is a decision. Falling + back to the spool's preset would silently reinstate what they cleared, + and it is the spool's preset that is wrong on that model.""" + maker, spool_id = await _spool(test_engine) + await _add(maker, spool_id, "H2C", "", None, None) + assert await _resolve(maker, spool_id, "H2C", "0.4") == (None, None) + + +class TestSpoolmanFlavour: + """Spoolman spools live in Spoolman; the override is Bambuddy's, keyed by + the remote id. Same cascade -- the two inventory modes must not drift.""" + + async def test_same_cascade(self, test_engine): + maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + async with maker() as db: + db.add( + SpoolmanFilamentPreset( + spoolman_spool_id=7, + printer_model="H2C", + nozzle_diameter="", + slicer_filament="GFSA09", + slicer_filament_name="Bambu PLA Basic @BBL H2C", + ) + ) + db.add( + SpoolmanFilamentPreset( + spoolman_spool_id=7, + printer_model="H2C", + nozzle_diameter="0.2", + slicer_filament="GFSA10", + slicer_filament_name="Bambu PLA Basic @BBL H2C 0.2 nozzle", + ) + ) + await db.commit() + + async def resolve(model, diameter, spool_id=7): + async with maker() as db: + return await resolve_spoolman_preset( + db, + spoolman_spool_id=spool_id, + printer_model=model, + nozzle_diameter=diameter, + fallback_filament=DEFAULT[0], + fallback_name=DEFAULT[1], + ) + + assert (await resolve("H2C", "0.4"))[0] == "GFSA09" + assert (await resolve("H2C", "0.2"))[0] == "GFSA10" + assert (await resolve("X1C", "0.4"))[0] == DEFAULT[0] + # Another spool's rows must not leak into this one. + assert (await resolve("H2C", "0.4", spool_id=8))[0] == DEFAULT[0] diff --git a/frontend/scripts/check-i18n-parity.mjs b/frontend/scripts/check-i18n-parity.mjs index 5d3f9cc7c..6aa28878a 100644 --- a/frontend/scripts/check-i18n-parity.mjs +++ b/frontend/scripts/check-i18n-parity.mjs @@ -421,6 +421,7 @@ const UK_COGNATES = [ // are used untranslated by Dutch slicer users. Each entry below was // checked individually against the Dutch translation in #2891. const NL_COGNATES = [ + '1 printer', '{{n}} printers', '1 week', '(25%, 50%, 75%)', 'Accent', 'AMS Filament Backup', '{{ams}} Slot {{slot}}', 'Auto', 'Auto Home', 'Bambu Cloud', 'Batch', 'Batches', 'Branch', 'Budget', diff --git a/frontend/src/__tests__/components/PrinterProfilesSection.test.tsx b/frontend/src/__tests__/components/PrinterProfilesSection.test.tsx new file mode 100644 index 000000000..60ba592f8 --- /dev/null +++ b/frontend/src/__tests__/components/PrinterProfilesSection.test.tsx @@ -0,0 +1,632 @@ +/** + * The spool form's Printers tab. + * + * Two things are keyed differently and the UI has to keep them apart: the + * filament preset belongs to the printer MODEL (an "@BBL X1C" preset is the + * same preset on every X1C), while a K profile is measured on one individual + * hotend and stays per printer, per extruder, per nozzle diameter. + * + * The layout is a model list plus a detail pane so that fleet size stops + * mattering -- these drive it with a deliberately mixed fleet: two machines of + * one model, a dual-nozzle machine with two different diameters fitted, and an + * offline printer. + */ + +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { screen, fireEvent, within } from '@testing-library/react'; +import { render } from '../utils'; +import { PrinterProfilesSection } from '../../components/spool-form/PrinterProfilesSection'; +import { presetKey, hotendKey } from '../../components/spool-form/utils'; +import { defaultFormData } from '../../components/spool-form/types'; +import type { + CalibrationProfile, + FilamentOption, + PresetChoice, + PrinterWithCalibrations, +} from '../../components/spool-form/types'; + +const OPTIONS: FilamentOption[] = [ + { code: 'GFSA00', name: 'Bambu PLA Basic @BBL X1C', displayName: 'Bambu PLA Basic @BBL X1C', isCustom: false, allCodes: ['GFSA00'], source: 'cloud' }, + { code: 'GFSA09', name: 'Bambu PLA Basic @BBL H2C', displayName: 'Bambu PLA Basic @BBL H2C', isCustom: false, allCodes: ['GFSA09'], source: 'cloud' }, + { code: 'GFSA21', name: 'Bambu PLA Basic @BBL H2C 0.2 nozzle', displayName: 'Bambu PLA Basic @BBL H2C 0.2 nozzle', isCustom: false, allCodes: ['GFSA21'], source: 'orca_cloud' }, + // Names no model at all -- the shape most user-authored and Orca presets + // have. Must stay offered everywhere: hiding what cannot be classified + // would hide most third-party profiles. + { code: 'LOCAL1', name: 'eSUN PETG Basic', displayName: 'eSUN PETG Basic (Local)', isCustom: true, allCodes: ['LOCAL1'], source: 'local' }, +]; + +/** The backend registry shape: "Bambu Lab " -> short code. */ +const PRINTER_MODELS: Record = { + 'Bambu Lab X1 Carbon': 'X1C', + 'Bambu Lab H2C': 'H2C', + 'Bambu Lab P1S': 'P1S', + 'Bambu Lab H2D': 'H2D', +}; + +function cal(overrides: Partial): CalibrationProfile { + return { + cali_idx: 1, + filament_id: 'GFL99', + setting_id: 'GFSL99', + name: 'PLA Basic', + k_value: 0.02, + n_coef: 1.0, + extruder_id: 0, + nozzle_diameter: '0.4', + ...overrides, + }; +} + +function printer( + id: number, + name: string, + model: string | null, + opts: { + connected?: boolean; + nozzleCount?: number; + calibrations?: CalibrationProfile[]; + nozzles?: { nozzle_diameter?: string }[]; + } = {}, +): PrinterWithCalibrations { + return { + printer: { + id, + name, + model, + connected: opts.connected ?? true, + nozzle_count: opts.nozzleCount ?? 1, + } as PrinterWithCalibrations['printer'], + calibrations: opts.calibrations ?? [cal({})], + nozzles: opts.nozzles, + }; +} + +/** Two X1Cs, one dual-nozzle H2C with 0.4 + 0.2 fitted, one offline P1S. */ +function fleet(): PrinterWithCalibrations[] { + return [ + printer(1, 'X1C-1', 'X1C'), + printer(2, 'X1C-2', 'X1C', { calibrations: [cal({ cali_idx: 2, k_value: 0.019 })] }), + printer(3, 'H2C-1', 'H2C', { + nozzleCount: 2, + nozzles: [{ nozzle_diameter: '0.4' }, { nozzle_diameter: '0.2' }], + calibrations: [ + cal({ cali_idx: 3, extruder_id: 0, nozzle_diameter: '0.4', k_value: 0.021 }), + cal({ cali_idx: 16, extruder_id: 1, nozzle_diameter: '0.2', k_value: 0.014, name: 'PLA 0.2' }), + ], + }), + printer(4, 'P1S-1', 'P1S', { connected: false }), + ]; +} + +interface HarnessProps { + printers?: PrinterWithCalibrations[]; + presets?: Map; + profiles?: Map; + onPresets?: (next: Map) => void; + onProfiles?: (next: Map) => void; + slicerFilament?: string; +} + +/** + * Renders the section with real state, so a click is asserted through the + * component's own update path rather than against a spy on the setter. + */ +function Harness({ + printers = fleet(), + presets = new Map(), + profiles = new Map(), + onPresets, + onProfiles, + slicerFilament = 'GFSA00', +}: HarnessProps) { + const [modelPresets, setModelPresets] = React.useState(presets); + const [selectedProfiles, setSelectedProfiles] = React.useState(profiles); + const [selectedGroupId, setSelectedGroupId] = React.useState(''); + + React.useEffect(() => { + onPresets?.(modelPresets); + }, [modelPresets, onPresets]); + React.useEffect(() => { + onProfiles?.(selectedProfiles); + }, [selectedProfiles, onProfiles]); + + return ( + + ); +} + +/** + * The preset pickers in the detail pane, in render order. + * + * Each is a button labelled " mm Filament preset" -- not a native + * cannot render one. + */ +function presetPickers(): HTMLElement[] { + return screen.getAllByRole('button', { name: /Filament preset$/ }); +} + +function presetPicker(model: string, diameter: string): HTMLElement { + return screen.getByRole('button', { name: `${model} ${diameter}mm Filament preset` }); +} + +/** Open one picker and return the options it is offering. */ +function openPicker(picker: HTMLElement): HTMLElement[] { + fireEvent.click(picker); + return screen.getAllByRole('option'); +} + +function optionNamed(pattern: RegExp | string): HTMLElement { + return screen.getByRole('option', { name: pattern }); +} + +/** + * A model's row in the left rail. By role: the model name also appears as the + * detail pane's heading, and the pane is full of other buttons. + */ +function modelRow(model: string): HTMLElement { + return screen.getByRole('tab', { name: new RegExp(`^\\s*${model}\\b`) }); +} + +/** The detail pane's heading, i.e. which model is currently open. */ +function openModel(): string { + return screen.getByRole('heading').textContent ?? ''; +} + +describe('PrinterProfilesSection — spool identity', () => { + it('names the spool being configured, colour included', () => { + // This tab is the one place you read printer names rather than filament, + // and the K lists below are filtered by exactly these fields -- so the line + // also explains an empty list. + const Named = () => { + const [presets, setPresets] = React.useState(new Map()); + const [profiles, setProfiles] = React.useState(new Map()); + const [group, setGroup] = React.useState(''); + return ( + + ); + }; + render(); + expect(screen.getByText('Bambu PLA Matte')).toBeInTheDocument(); + expect(screen.getByText('Scarlet Red')).toBeInTheDocument(); + }); + + it('renders no identity bar at all when the spool has nothing to name yet', () => { + const Blank = () => { + const [presets, setPresets] = React.useState(new Map()); + const [profiles, setProfiles] = React.useState(new Map()); + const [group, setGroup] = React.useState(''); + return ( + + ); + }; + render(); + // An empty bar, or one repeating the K section's "select a material first", + // would be noise -- the K section already says it once. + expect(screen.getAllByText(/select a material first/i)).toHaveLength(1); + }); +}); + +describe('PrinterProfilesSection — model list', () => { + it('lists each model once, however many machines it has', () => { + render(); + expect(modelRow('X1C')).toBeInTheDocument(); + expect(modelRow('H2C')).toBeInTheDocument(); + expect(modelRow('P1S')).toBeInTheDocument(); + // Two X1Cs, one row -- the rail is per model, not per machine. + expect(within(modelRow('X1C')).getByText('2 printers')).toBeInTheDocument(); + }); + + it('shows the first model by default and switches on click', () => { + render(); + // Alphabetical: H2C first. Its machine is named in the detail pane. + expect(openModel()).toBe('H2C'); + // Named twice on purpose: once in the pane subtitle, once on its own card. + expect(screen.getAllByText('H2C-1').length).toBeGreaterThan(0); + + fireEvent.click(modelRow('X1C')); + expect(openModel()).toBe('X1C'); + expect(screen.getByText('X1C-1, X1C-2')).toBeInTheDocument(); + }); + + it('counts hotends with no K profile chosen', () => { + render(); + // H2C has two hotends and nothing chosen yet. + expect(within(modelRow('H2C')).getByText('2')).toBeInTheDocument(); + }); +}); + +describe('PrinterProfilesSection — filament preset', () => { + it('starts inherited and stores an override when one is picked', () => { + let latest: Map = new Map(); + render( { latest = next; }} />); + + // One badge per preset row -- one row per nozzle size. + expect(screen.getAllByText('inherited')).toHaveLength(presetPickers().length); + + fireEvent.click(presetPicker('H2C', '0.4')); + fireEvent.click(optionNamed('Bambu PLA Basic @BBL H2C Bambu Cloud')); + + expect(latest.get(presetKey('H2C', '0.4'))).toEqual({ + code: 'GFSA09', + name: 'Bambu PLA Basic @BBL H2C', + }); + expect(screen.getByText('override')).toBeInTheDocument(); + }); + + it('clearing an override removes the row rather than storing the spool value', () => { + // A row repeating the spool's own preset would freeze it: later edits to + // the spool preset would stop reaching this model. Absent means inherit. + let latest: Map = new Map(); + render( + { latest = next; }} + />, + ); + + fireEvent.click(presetPicker('H2C', '0.4')); + fireEvent.click(optionNamed(/use the spool's preset/i)); + + expect(latest.has(presetKey('H2C', '0.4'))).toBe(false); + }); + + it('offers one preset row per nozzle size and nothing above them', () => { + render(); + // Every standard size, not only the ones fitted: a spool is configured + // once and nozzles get swapped. No model-wide row -- the preset lands on + // an AMS slot and a slot feeds exactly one nozzle. + expect(presetPickers()).toHaveLength(4); + // (Each size also labels a K-profile grid row, hence getAll.) + expect(screen.getAllByText('0.4mm').length).toBeGreaterThan(0); + expect(screen.getAllByText('0.2mm').length).toBeGreaterThan(0); + + // Same for a model with a single diameter fitted across both machines. + fireEvent.click(modelRow('X1C')); + expect(presetPickers()).toHaveLength(4); + }); + + it('a pick is stored under the size it was made on', () => { + let latest: Map = new Map(); + render( { latest = next; }} />); + + fireEvent.click(presetPicker('H2C', '0.2')); + fireEvent.click(optionNamed(/0\.2 nozzle/)); + + expect(latest.get(presetKey('H2C', '0.2'))?.code).toBe('GFSA21'); + // Only that size -- the other rows are untouched. + expect(latest.size).toBe(1); + }); + + it('refuses to offer a preset for a printer whose model is unknown', () => { + render(); + expect(screen.getByText(/has not reported its model/i)).toBeInTheDocument(); + }); +}); + +describe('PrinterProfilesSection — presets offered per model', () => { + function optionNames(picker: HTMLElement): string[] { + return openPicker(picker).map(o => o.textContent ?? ''); + } + + it('offers a model only the presets that name it', () => { + render(); + const names = optionNames(presetPicker('H2C', '0.4')); + + expect(names.some(n => n.includes('Bambu PLA Basic @BBL H2C'))).toBe(true); + expect(names.some(n => n.includes('Bambu PLA Basic @BBL X1C'))).toBe(false); + }); + + it('keeps presets whose model cannot be read from the name', () => { + render(); + const names = optionNames(presetPicker('H2C', '0.4')); + expect(names.some(n => n.includes('eSUN PETG Basic (Local)'))).toBe(true); + }); + + it('switching model switches which presets are offered', () => { + render(); + fireEvent.click(modelRow('X1C')); + const names = optionNames(presetPicker('X1C', '0.4')); + expect(names.some(n => n.includes('Bambu PLA Basic @BBL X1C'))).toBe(true); + expect(names.some(n => n.includes('Bambu PLA Basic @BBL H2C'))).toBe(false); + }); + + it('never hides an override that is already saved', () => { + // The stored value may name another model -- picked before the filter + // existed, or by hand. Dropping it from the list would blank the control + // and quietly change what gets saved. + render( + , + ); + fireEvent.click(modelRow('X1C')); + const picker = presetPicker('X1C', '0.4'); + expect(picker.textContent).toContain('Bambu PLA Basic @BBL H2C'); + expect(optionNames(picker).some(n => n.includes('Bambu PLA Basic @BBL H2C'))).toBe(true); + }); + + it('badges each preset with the source it came from', () => { + // The same filament exists as a cloud preset, an imported one and a + // built-in, and which one is picked decides what reaches the printer -- + // so the origin is shown here exactly as the Configure AMS Slot modal + // shows it. + render(); + const options = openPicker(presetPicker('H2C', '0.4')); + + const cloud = options.find(o => o.textContent?.includes('@BBL H2C')); + expect(cloud?.textContent).toContain('Bambu Cloud'); + const local = options.find(o => o.textContent?.includes('eSUN PETG Basic')); + expect(local?.textContent).toContain('Local'); + }); + + it('filters the list as you type', () => { + render(); + fireEvent.click(presetPicker('H2C', '0.4')); + fireEvent.change(screen.getByPlaceholderText(/search filament presets/i), { + target: { value: 'esun' }, + }); + + const names = screen.getAllByRole('option').map(o => o.textContent ?? ''); + expect(names.some(n => n.includes('eSUN'))).toBe(true); + expect(names.some(n => n.includes('Bambu PLA Basic'))).toBe(false); + }); +}); + +describe('PrinterProfilesSection — every nozzle size', () => { + it('offers a preset row for all four standard sizes, fitted or not', () => { + // A spool is configured once and nozzles get swapped. X1C has only a 0.4 + // fitted, and must still offer 0.2 / 0.6 / 0.8. + render(); + fireEvent.click(modelRow('X1C')); + + expect(presetPickers()).toHaveLength(4); + for (const size of ['0.2mm', '0.4mm', '0.6mm', '0.8mm']) { + expect(screen.getAllByText(size).length).toBeGreaterThan(0); + } + }); + + it('stores a preset for a size that is not currently fitted', () => { + let latest: Map = new Map(); + render( { latest = next; }} />); + fireEvent.click(modelRow('X1C')); + + fireEvent.click(presetPicker('X1C', '0.6')); + fireEvent.click(optionNamed('Bambu PLA Basic @BBL X1C Bambu Cloud')); + + expect(latest.get(presetKey('X1C', '0.6'))?.code).toBe('GFSA00'); + }); + + it('offers a K profile for a size the printer has profiles for but has not fitted', () => { + // fetchPrinterCalibrations now asks for every standard size, so a 0.6 + // profile reaches the picker without a 0.6 being screwed in. + let latest: Map = new Map(); + const withSpare = [ + printer(1, 'X1C-1', 'X1C', { + nozzles: [{ nozzle_diameter: '0.4' }], + calibrations: [ + cal({ cali_idx: 1, nozzle_diameter: '0.4' }), + cal({ cali_idx: 7, nozzle_diameter: '0.6', k_value: 0.026, name: 'PLA 0.6' }), + ], + }), + ]; + render( { latest = next; }} />); + + fireEvent.change(screen.getByLabelText('X1C-1 Nozzle 0.6mm'), { target: { value: '7' } }); + expect(latest.get(hotendKey(1, 0, '0.6'))?.cali_idx).toBe(7); + }); +}); + +describe('PrinterProfilesSection — auto-match', () => { + it('fills every nozzle size of each model, preferring the variant for that size', () => { + let latest: Map = new Map(); + render( { latest = next; }} />); + + fireEvent.click(screen.getByRole('button', { name: /auto-match/i })); + + // The H2C list holds a plain "@BBL H2C" and an "@BBL H2C 0.2 nozzle". The + // 0.2 row takes the sized variant; the rest take the unsized one. + expect(latest.get(presetKey('H2C', '0.2'))?.code).toBe('GFSA21'); + expect(latest.get(presetKey('H2C', '0.4'))?.code).toBe('GFSA09'); + expect(latest.get(presetKey('H2C', '0.8'))?.code).toBe('GFSA09'); + // Nothing is written to the model-wide key -- there is no row that shows + // it, and a stored value nobody can see is a value nobody can undo. + expect(latest.has(presetKey('H2C', ''))).toBe(false); + }); + + it('leaves a model with no matching variant inherited', () => { + let latest: Map = new Map(); + render( { latest = next; }} />); + + fireEvent.click(screen.getByRole('button', { name: /auto-match/i })); + + // P1S has no preset of this filament in the list, and an approximate one + // is worse than falling back to the spool's own. + for (const size of ['', '0.2', '0.4', '0.6', '0.8']) { + expect(latest.has(presetKey('P1S', size))).toBe(false); + } + }); +}); + +describe('PrinterProfilesSection — K profiles', () => { + it('keys a chosen profile by printer, extruder and diameter', () => { + let latest: Map = new Map(); + render( { latest = next; }} />); + + // Addressed by cell rather than by DOM order: the grid is size down the + // side and hotend across, and only cells the printer has a calibration for + // hold a dropdown at all. + fireEvent.change(screen.getByLabelText('H2C-1 Left Nozzle 0.2mm'), { + target: { value: '16' }, + }); + + // Extruder 1 at 0.2mm -- not the 0.4 hotend, and not keyed by cali_idx, + // which is numbered per nozzle and repeats across hotends. + expect(latest.get(hotendKey(3, 1, '0.2'))?.cali_idx).toBe(16); + expect(latest.has(hotendKey(3, 0, '0.4'))).toBe(false); + }); + + it('lays the grid out as nozzle size by hotend, with a dash where the printer has nothing', () => { + render(); + + // The H2C has a 0.2 profile on the left hotend and a 0.4 on the right. + expect(screen.getByLabelText('H2C-1 Left Nozzle 0.2mm')).toBeInTheDocument(); + expect(screen.getByLabelText('H2C-1 Right Nozzle 0.4mm')).toBeInTheDocument(); + // The other six cells have no calibration to offer, so no control. + expect(screen.queryByLabelText('H2C-1 Left Nozzle 0.4mm')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('H2C-1 Right Nozzle 0.8mm')).not.toBeInTheDocument(); + // Every size is still listed down the side rather than looking forgotten. + for (const size of ['0.2mm', '0.4mm', '0.6mm', '0.8mm']) { + expect(screen.getAllByText(size).length).toBeGreaterThan(0); + } + }); + + it('gives a single-nozzle machine one unnamed column', () => { + render(); + fireEvent.click(modelRow('X1C')); + + expect(screen.getByLabelText('X1C-1 Nozzle 0.4mm')).toBeInTheDocument(); + expect(screen.queryByLabelText(/X1C-1 (Left|Right) Nozzle/)).not.toBeInTheDocument(); + }); + + it('choosing again on one hotend replaces rather than accumulates', () => { + let latest: Map = new Map(); + render( { latest = next; }} />); + + fireEvent.click(modelRow('X1C')); + const kSelect = screen.getByLabelText('X1C-1 Nozzle 0.4mm'); + + fireEvent.change(kSelect, { target: { value: '1' } }); + fireEvent.change(kSelect, { target: { value: '' } }); + + expect(latest.size).toBe(0); + }); + + it('says an offline printer cannot be configured instead of showing empty rows', () => { + render(); + fireEvent.click(modelRow('P1S')); + expect(screen.getByText(/printer is offline/i)).toBeInTheDocument(); + }); + + it('labels the hotends of a dual-nozzle machine by side', () => { + render(); + expect(screen.getByText('Right Nozzle')).toBeInTheDocument(); + expect(screen.getByText('Left Nozzle')).toBeInTheDocument(); + + // A single-nozzle machine has no side to name. + fireEvent.click(modelRow('X1C')); + expect(screen.queryByText('Right Nozzle')).not.toBeInTheDocument(); + expect(screen.getAllByText('Nozzle').length).toBeGreaterThan(0); + }); +}); + +describe('PrinterProfilesSection — empty fleet', () => { + it('says so rather than rendering an empty two-pane layout', () => { + render(); + expect(screen.getByText(/no printers configured/i)).toBeInTheDocument(); + }); + + it('waits rather than claiming there are no printers while still loading', () => { + // Reading each printer's calibration table is several MQTT round trips, so + // this gap is seconds. Saying "no printers configured" during it is a wrong + // answer about the user's setup, not a slow one. + const Loading = () => { + const [presets, setPresets] = React.useState(new Map()); + const [profiles, setProfiles] = React.useState(new Map()); + const [group, setGroup] = React.useState(''); + return ( + + ); + }; + render(); + expect(screen.queryByText(/no printers configured/i)).not.toBeInTheDocument(); + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); +}); + +describe('PrinterProfilesSection — no material yet', () => { + it('asks for a material before offering K profiles', () => { + const Bare = () => { + const [presets, setPresets] = React.useState(new Map()); + const [profiles, setProfiles] = React.useState(new Map()); + const [group, setGroup] = React.useState(''); + return ( + + ); + }; + render(); + expect(screen.getByText(/select a material first/i)).toBeInTheDocument(); + // The preset half still works -- it does not depend on the material. + expect(screen.getByText('Filament preset')).toBeInTheDocument(); + }); +}); + +describe('PrinterProfilesSection — vi sanity', () => { + it('does not call any API of its own', () => { + // The section is presentational: everything it changes goes through the + // props, and SpoolFormModal is what persists it on save. + const spy = vi.fn(); + render(); + expect(spy).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/__tests__/components/SpoolFormBulk.test.tsx b/frontend/src/__tests__/components/SpoolFormBulk.test.tsx index 4be023e5a..ea49caeaa 100644 --- a/frontend/src/__tests__/components/SpoolFormBulk.test.tsx +++ b/frontend/src/__tests__/components/SpoolFormBulk.test.tsx @@ -5,7 +5,7 @@ * - Quick-add toggle appears only in create mode * - Quick-add mode shows brand and subtype as optional (no asterisk) * - Quick-add mode hides slicer preset field - * - Quick-add mode hides PA Profile tab + * - Quick-add mode hides the Printers tab * - Quantity field is only rendered in quick-add mode * - Quantity field is hidden in edit mode * - Bulk create calls bulkCreateSpools when quantity > 1 @@ -43,6 +43,10 @@ vi.mock('../../api/client', () => ({ ]), updateSpool: vi.fn().mockResolvedValue({ id: 1 }), saveSpoolKProfiles: vi.fn().mockResolvedValue([]), + getSpoolFilamentPresets: vi.fn().mockResolvedValue([]), + saveSpoolFilamentPresets: vi.fn().mockResolvedValue([]), + getSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]), + saveSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]), }, })); @@ -175,7 +179,7 @@ describe('SpoolFormModal quick-add toggle', () => { expect(screen.queryByText('Quick Add (Stock)')).not.toBeInTheDocument(); }); - it('hides PA Profile tab when quick-add is enabled', async () => { + it('hides the Printers tab when quick-add is enabled', async () => { render( { expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument(); }); - // PA Profile tab should be visible initially - expect(screen.getByText('PA Profile')).toBeInTheDocument(); + // Printers tab should be visible initially + expect(screen.getByText('Printers')).toBeInTheDocument(); // Toggle quick-add on — the toggle is a button[role="switch"] sibling of the label const toggleButtons = screen.getAllByRole('button'); @@ -202,9 +206,9 @@ describe('SpoolFormModal quick-add toggle', () => { expect(quickAddToggle).toBeTruthy(); fireEvent.click(quickAddToggle!); - // PA Profile tab should be hidden + // Printers tab should be hidden await waitFor(() => { - expect(screen.queryByText('PA Profile')).not.toBeInTheDocument(); + expect(screen.queryByText('Printers')).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/__tests__/components/SpoolFormEditRelaxed.test.tsx b/frontend/src/__tests__/components/SpoolFormEditRelaxed.test.tsx index 1bb8fef1a..9b476c3d2 100644 --- a/frontend/src/__tests__/components/SpoolFormEditRelaxed.test.tsx +++ b/frontend/src/__tests__/components/SpoolFormEditRelaxed.test.tsx @@ -41,6 +41,10 @@ vi.mock('../../api/client', () => ({ createSpool: vi.fn().mockResolvedValue({ id: 99 }), updateSpool: vi.fn().mockResolvedValue({ id: 7 }), saveSpoolKProfiles: vi.fn().mockResolvedValue([]), + getSpoolFilamentPresets: vi.fn().mockResolvedValue([]), + saveSpoolFilamentPresets: vi.fn().mockResolvedValue([]), + getSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]), + saveSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]), getSpoolmanInventoryFilaments: vi.fn().mockResolvedValue([]), getAssignments: vi.fn().mockResolvedValue([]), unassignSpool: vi.fn().mockResolvedValue({}), @@ -175,8 +179,8 @@ describe('SpoolFormModal relaxed edit/copy validation (#1905)', () => { const presetInput = screen.getByPlaceholderText('Search filament presets...'); fireEvent.focus(presetInput); - await waitFor(() => expect(screen.getByRole('button', { name: 'Generic ASA' })).toBeInTheDocument()); - fireEvent.click(screen.getByRole('button', { name: 'Generic ASA' })); + await waitFor(() => expect(screen.getByRole('button', { name: /^Generic ASA\b/ })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /^Generic ASA\b/ })); // parsePresetName('Generic ASA') yields brand "Generic" — it must not // replace the manufacturer the spool already carries. @@ -192,8 +196,8 @@ describe('SpoolFormModal relaxed edit/copy validation (#1905)', () => { const presetInput = screen.getByPlaceholderText('Search filament presets...'); fireEvent.focus(presetInput); - await waitFor(() => expect(screen.getByRole('button', { name: 'Generic ASA' })).toBeInTheDocument()); - fireEvent.click(screen.getByRole('button', { name: 'Generic ASA' })); + await waitFor(() => expect(screen.getByRole('button', { name: /^Generic ASA\b/ })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /^Generic ASA\b/ })); expect(screen.getByPlaceholderText('Search brand...')).toHaveValue('Generic'); expect(screen.getByPlaceholderText('Select material...')).toHaveValue('ASA'); diff --git a/frontend/src/__tests__/components/SpoolFormModal.test.tsx b/frontend/src/__tests__/components/SpoolFormModal.test.tsx index 8dbbf6266..9537ae693 100644 --- a/frontend/src/__tests__/components/SpoolFormModal.test.tsx +++ b/frontend/src/__tests__/components/SpoolFormModal.test.tsx @@ -31,6 +31,10 @@ vi.mock('../../api/client', () => ({ createSpoolmanInventorySpool: vi.fn().mockResolvedValue({ id: 88 }), updateSpool: vi.fn().mockResolvedValue({ id: 1 }), saveSpoolKProfiles: vi.fn().mockResolvedValue([]), + getSpoolFilamentPresets: vi.fn().mockResolvedValue([]), + saveSpoolFilamentPresets: vi.fn().mockResolvedValue([]), + getSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]), + saveSpoolmanFilamentPresets: vi.fn().mockResolvedValue([]), saveSpoolmanKProfiles: vi.fn().mockResolvedValue([]), updateSpoolmanInventorySpool: vi.fn().mockResolvedValue({ id: 42 }), bulkCreateSpoolmanInventorySpools: vi.fn().mockResolvedValue({ @@ -75,6 +79,18 @@ vi.mock('../../contexts/ToastContext', async (importOriginal) => { import { api } from '../../api/client'; +/** + * Open the spool form's "Color & Cost" tab. + * + * The form is split across three tabs -- Filament (identity + preset), Color & + * Cost (colour, spool weights, price, category, location) and Printers + * (per-model preset + per-hotend K profile). Fields that used to sit in one + * long scroll under Filament now need their tab opened first. + */ +function openColorAndCostTab() { + fireEvent.click(screen.getByText('Color & Cost')); +} + const existingSpool: InventorySpool = { id: 1, material: 'PLA', @@ -159,6 +175,8 @@ describe('SpoolFormModal weightTouched', () => { expect(screen.getByText('Edit Spool')).toBeInTheDocument(); }); + openColorAndCostTab(); + // The remaining weight is (label_weight - weight_used) = 1000 - 300 = 700. // The input is a number input displaying 700. Find it by its displayed value. const remainingInput = screen.getByDisplayValue('700'); @@ -236,6 +254,8 @@ describe('SpoolFormModal weightTouched', () => { expect(screen.getByText('Edit Spool')).toBeInTheDocument(); }); + openColorAndCostTab(); + // Change the note field (unrelated to catalog ID) const noteInputs = screen.getAllByPlaceholderText(/note/i); expect(noteInputs.length).toBeGreaterThan(0); @@ -278,6 +298,8 @@ describe('SpoolFormModal weightTouched', () => { expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument(); }); + openColorAndCostTab(); + // Wait for catalog to load await waitFor(() => { expect(api.getSpoolCatalog).toHaveBeenCalled(); @@ -579,6 +601,8 @@ describe('SpoolFormModal weightTouched', () => { expect(screen.getByText('Edit Spool')).toBeInTheDocument(); }); + openColorAndCostTab(); + // Wait for catalog to load await waitFor(() => { expect(api.getSpoolCatalog).toHaveBeenCalled(); @@ -631,7 +655,7 @@ describe('SpoolFormModal Spoolman K-profile support', () => { vi.clearAllMocks(); }); - it('shows PA Profile tab for Spoolman spools in non-quickAdd mode', async () => { + it('shows the Printers tab for Spoolman spools in non-quickAdd mode', async () => { render( { expect(screen.getByText('Edit Spool')).toBeInTheDocument(); }); - // PA Profile tab should be visible in Spoolman mode - expect(screen.getByText('PA Profile')).toBeInTheDocument(); + // Printers tab should be visible in Spoolman mode + expect(screen.getByText('Printers')).toBeInTheDocument(); }); it('calls saveSpoolmanKProfiles (not saveSpoolKProfiles) on update in Spoolman mode', async () => { @@ -680,6 +704,34 @@ describe('SpoolFormModal Spoolman K-profile support', () => { }); expect(api.saveSpoolKProfiles).not.toHaveBeenCalled(); }); + + it('saves the per-model preset overrides alongside the K profiles', async () => { + // Both are full replacements and both are written on every save: that is + // how the user clears the last profile or the last override. The Spoolman + // pair must be the one called in Spoolman mode -- the two inventory modes + // have drifted apart on this path before (#1713). + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Edit Spool')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => { + expect(api.saveSpoolmanFilamentPresets).toHaveBeenCalledWith(42, []); + }); + expect(api.saveSpoolFilamentPresets).not.toHaveBeenCalled(); + }); }); // --------------------------------------------------------------------------- @@ -888,10 +940,16 @@ describe('SpoolFormModal — SpoolmanFilamentPicker integration (T2)', () => { expect(screen.getByTestId('picker-selected-id').textContent).toBe('7'); }); + openColorAndCostTab(); + // Manually edit the color_name field (a linked field) const colorNameInput = screen.getByPlaceholderText('Jade White, Fire Red...'); fireEvent.change(colorNameInput, { target: { value: 'Custom Blue' } }); + // Back to the Filament tab: the catalog picker only renders there, so the + // link state has to be read where it lives. + fireEvent.click(screen.getByText('Filament Info')); + // spoolman_filament_id must be cleared (picker shows 'none') await waitFor(() => { expect(screen.getByTestId('picker-selected-id').textContent).toBe('none'); @@ -1073,6 +1131,8 @@ describe('SpoolFormModal locationIdTouched', () => { expect(screen.getByText('Edit Spool')).toBeInTheDocument(); }); + openColorAndCostTab(); + // Change storage location via the catalog dropdown const locationSelect = screen.getByLabelText(/storage location/i); fireEvent.change(locationSelect, { target: { value: '2' } }); diff --git a/frontend/src/__tests__/components/spool-form/fetchPrinterCalibrations.test.ts b/frontend/src/__tests__/components/spool-form/fetchPrinterCalibrations.test.ts new file mode 100644 index 000000000..45cb59e1c --- /dev/null +++ b/frontend/src/__tests__/components/spool-form/fetchPrinterCalibrations.test.ts @@ -0,0 +1,118 @@ +/** + * `fetchPrinterCalibrations` asks the printer for its K-profile table. + * + * Two properties, both measured on real hardware rather than reasoned about: + * + * 1. It asks for EVERY standard nozzle size, not only the sizes currently + * fitted. A K profile is stored on the printer per diameter and survives a + * nozzle swap, so fetching only what is screwed in right now hides a 0.6 + * profile until a 0.6 is fitted, and stops a spool being prepared for a + * nozzle that is about to be changed to. + * + * 2. It asks for them ONE AT A TIME. H2-series firmware answers only the first + * one or two of a concurrent burst of `extrusion_cali_get` and silently + * drops the rest; each dropped request then costs a 5-second timeout before + * the retry. Measured on an H2C and an H2D: four parallel requests took 11s + * and 23s, against roughly 1s sent in series. An X1C answers all four + * concurrently, which is why this stayed hidden while only dual-diameter + * printers ever sent more than one request. + * + * The second is the one worth a test: it is invisible in every unit-level + * result (the same rows come back either way) and only shows up as a stall on + * one brand of hardware. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const getKProfiles = vi.fn(); + +vi.mock('../../../api/client', () => ({ + api: { + get getKProfiles() { + return getKProfiles; + }, + }, +})); + +import { fetchPrinterCalibrations } from '../../../components/spool-form/utils'; +import { STANDARD_NOZZLE_DIAMETERS } from '../../../components/spool-form/constants'; + +function profile(slotId: number, diameter: string) { + return { + slot_id: slotId, + filament_id: 'GFL99', + setting_id: 'GFSL99', + name: `PLA ${diameter}`, + k_value: '0.020', + n_coef: '1.0', + extruder_id: 0, + nozzle_diameter: diameter, + }; +} + +describe('fetchPrinterCalibrations', () => { + beforeEach(() => { + getKProfiles.mockReset(); + }); + + it('asks for every standard nozzle size, not just the fitted one', async () => { + getKProfiles.mockResolvedValue({ profiles: [] }); + + await fetchPrinterCalibrations(1, { nozzles: [{ nozzle_diameter: '0.4' }] }); + + const asked = getKProfiles.mock.calls.map(([, diameter]) => diameter); + expect(asked).toEqual(expect.arrayContaining(STANDARD_NOZZLE_DIAMETERS)); + }); + + it('includes an unusual fitted diameter alongside the standard sizes', async () => { + getKProfiles.mockResolvedValue({ profiles: [] }); + + await fetchPrinterCalibrations(1, { nozzles: [{ nozzle_diameter: '1.0' }] }); + + const asked = getKProfiles.mock.calls.map(([, diameter]) => diameter); + expect(asked).toContain('1.0'); + // Asked once each, no duplicate for a size that is both standard and fitted. + expect(new Set(asked).size).toBe(asked.length); + }); + + it('never has two requests in flight at once', async () => { + let inFlight = 0; + let maxInFlight = 0; + getKProfiles.mockImplementation(async () => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise(resolve => setTimeout(resolve, 0)); + inFlight--; + return { profiles: [] }; + }); + + await fetchPrinterCalibrations(1, { nozzles: [{ nozzle_diameter: '0.4' }] }); + + expect(getKProfiles.mock.calls.length).toBeGreaterThan(1); + expect(maxInFlight).toBe(1); + }); + + it('keeps the diameters that answered when one request fails', async () => { + getKProfiles.mockImplementation(async (_id: number, diameter: string) => { + if (diameter === '0.6') throw new Error('printer said no'); + return { profiles: [profile(1, diameter)] }; + }); + + const rows = await fetchPrinterCalibrations(1, { nozzles: [{ nozzle_diameter: '0.4' }] }); + + const diameters = rows.map(r => r.nozzle_diameter); + expect(diameters).toContain('0.4'); + expect(diameters).not.toContain('0.6'); + }); + + it('flattens every size into one list of calibrations', async () => { + getKProfiles.mockImplementation(async (_id: number, diameter: string) => ({ + profiles: diameter === '0.8' ? [] : [profile(Number(diameter.replace('.', '')), diameter)], + })); + + const rows = await fetchPrinterCalibrations(1, { nozzles: [{ nozzle_diameter: '0.4' }] }); + + expect(rows.map(r => r.nozzle_diameter).sort()).toEqual(['0.2', '0.4', '0.6']); + expect(rows[0].k_value).toBeCloseTo(0.02); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 43abe666b..8a1cd66db 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -3628,6 +3628,38 @@ export interface SpoolKProfileInput { setting_id?: string | null; } +/** + * One per-printer-model override of a spool's slicer filament preset. + * + * A cloud or Orca preset is bound to a printer model ("@BBL X1C"), so the + * spool's single `slicer_filament` is wrong as soon as the spool is used on a + * second model. `nozzle_diameter` is "" for the model's own default and a bare + * decimal ("0.2") for a per-hotend exception; the backend resolves + * exact (model, diameter) -> (model, "") -> the spool's own preset. + */ +export interface SlotSpoolDefaults { + slicer_filament: string | null; + slicer_filament_name: string | null; + cali_idx: number | null; + k_value: number | null; + profile_name: string | null; + extruder: number | null; + nozzle_diameter: string; +} + +export interface SpoolFilamentPresetInput { + printer_model: string; + nozzle_diameter: string; + slicer_filament: string | null; + slicer_filament_name: string | null; +} + +export interface SpoolFilamentPreset extends SpoolFilamentPresetInput { + id: number; + spool_id: number; + created_at: string; +} + /** One inventory-bound AMS slot, as returned by `/printers/{id}/inventory-remain`. */ export interface SlotMaterial { ams_id: number; @@ -6386,6 +6418,20 @@ export const api = { method: 'PUT', body: JSON.stringify(profiles), }), + /** + * What the spool assigned to this slot is configured to use *here* -- its + * per-printer-model filament preset and the K profile for this slot's hotend. + * Nulls when the slot holds no known spool. + */ + getSlotSpoolDefaults: (printerId: number, amsId: number, trayId: number) => + request(`/printers/${printerId}/slots/${amsId}/${trayId}/spool-defaults`), + getSpoolFilamentPresets: (spoolId: number) => + request(`/inventory/spools/${spoolId}/filament-presets`), + saveSpoolFilamentPresets: (spoolId: number, presets: SpoolFilamentPresetInput[]) => + request(`/inventory/spools/${spoolId}/filament-presets`, { + method: 'PUT', + body: JSON.stringify(presets), + }), getAssignments: (printerId?: number) => request(`/inventory/assignments${printerId ? `?printer_id=${printerId}` : ''}`), assignSpool: (data: { spool_id: number; printer_id: number; ams_id: number; tray_id: number }) => @@ -6628,6 +6674,15 @@ export const api = { body: JSON.stringify(profiles), }), + getSpoolmanFilamentPresets: (spoolId: number) => + request(`/spoolman/inventory/spools/${spoolId}/filament-presets`), + + saveSpoolmanFilamentPresets: (spoolId: number, presets: SpoolFilamentPresetInput[]) => + request(`/spoolman/inventory/spools/${spoolId}/filament-presets`, { + method: 'PUT', + body: JSON.stringify(presets), + }), + // Updates getVersion: () => request('/updates/version'), checkForUpdates: () => request('/updates/check'), diff --git a/frontend/src/components/ConfigureAmsSlotModal.tsx b/frontend/src/components/ConfigureAmsSlotModal.tsx index b1c47f68b..beaae917c 100644 --- a/frontend/src/components/ConfigureAmsSlotModal.tsx +++ b/frontend/src/components/ConfigureAmsSlotModal.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'lucide-react'; import { api } from '../api/client'; import type { KProfile } from '../api/client'; -import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex } from '../utils/slicerPrinterMatch'; +import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex, extractPresetModel } from '../utils/slicerPrinterMatch'; import { toFilamentId } from './spool-form/utils'; import { Button } from './Button'; import { getAmsLabel } from '../utils/amsHelpers'; @@ -245,82 +245,6 @@ function colorNameToHex(name: string): string | null { } // Escape regex metacharacters and turn whitespace into ``\s+`` so a literal -// model token compiles to a flexible-whitespace word-boundary regex. -function _tokenToRegex(token: string): RegExp { - const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+'); - return new RegExp(`\\b${escaped}\\b`, 'i'); -} - -// Extract printer model from a preset name → normalized short code -// (e.g. "X1C", "H2D"). Two strategies in order: -// -// (1) ``@`` suffix — the BambuStudio naming convention. Two shapes: -// - "@BBL X1C 0.4 nozzle" → "X1C" (short-code form, -// Bambu Cloud system presets) -// - "@Bambu Lab X1 Carbon 0.4 nozzle" → "X1C" (long-form, used by -// user-renamed Bambu Cloud presets and most Orca Cloud profiles — -// reverse-looked-up via the backend printer-model registry) -// -// (2) Body scan — many user-authored / Orca Cloud presets put the printer -// model at the START of the name with no @ suffix at all (the literal -// shape that surfaced #1623: "X1C eSUN PETG-Basic Filament"). Scan the -// name for any known model token (every long-name fragment + every short -// code from the registry) and return the first match. Long-first sort -// keeps "A1 Mini" / "X1 Carbon" / "H2D Pro" from being eaten by their -// shorter sibling ("A1" / "X1" / "H2D"). Word-boundary regex prevents -// false-positives on partial substrings (e.g. "PA1" doesn't match "A1", -// "X1Box" doesn't match "X1"). -// -// Returns null when neither strategy resolves; the caller keeps such -// presets visible (can't filter what we can't classify). -// -// ``printerModelsLongToShort`` is the backend's PRINTER_MODEL_MAP shape: -// keys are "Bambu Lab ", values are short codes. -function extractPresetModel( - name: string, - printerModelsLongToShort: Record, -): string | null { - const atIdx = name.indexOf('@'); - if (atIdx >= 0) { - const suffix = name.slice(atIdx + 1).trim(); - const bblMatch = suffix.match(/^BBL\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i); - if (bblMatch) return bblMatch[1].trim(); - const longMatch = suffix.match(/^Bambu Lab\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i); - if (longMatch) { - const longFragment = longMatch[1].trim(); - const fullKey = `Bambu Lab ${longFragment}`; - if (printerModelsLongToShort[fullKey]) return printerModelsLongToShort[fullKey]; - const lower = fullKey.toLowerCase(); - for (const [k, v] of Object.entries(printerModelsLongToShort)) { - if (k.toLowerCase() === lower) return v; - } - return longFragment; - } - } - - // Body scan — accumulate {token, short} pairs and try long-first. - const tokens: Array<{ token: string; short: string }> = []; - const seen = new Set(); - for (const [longName, short] of Object.entries(printerModelsLongToShort)) { - const fragment = longName.replace(/^Bambu Lab\s+/, ''); - const key = fragment.toLowerCase(); - if (!seen.has(key)) { - tokens.push({ token: fragment, short }); - seen.add(key); - } - const shortKey = short.toLowerCase(); - if (!seen.has(shortKey)) { - tokens.push({ token: short, short }); - seen.add(shortKey); - } - } - tokens.sort((a, b) => b.token.length - a.token.length); - for (const { token, short } of tokens) { - if (_tokenToRegex(token).test(name)) return short; - } - return null; -} - export function ConfigureAmsSlotModal({ isOpen, onClose, @@ -415,6 +339,19 @@ export function ConfigureAmsSlotModal({ staleTime: Infinity, }); + // What the spool in this slot is configured to use here: its filament preset + // for this printer's MODEL and its K profile for this slot's hotend. Those + // are the values the user set on the spool, so they are the right defaults + // for a dialog that configures the slot that spool sits in -- the slot's own + // last manual configuration and the tray's RFID data are the fallbacks, not + // the other way round. + const { data: slotSpoolDefaults } = useQuery({ + queryKey: ['slot-spool-defaults', printerId, slotInfo.amsId, slotInfo.trayId], + queryFn: () => api.getSlotSpoolDefaults(printerId, slotInfo.amsId, slotInfo.trayId), + enabled: isOpen, + staleTime: 0, + }); + const compatIndex = useMemo( () => buildCompatibilityIndex(printerModelsData ?? {}), [printerModelsData], @@ -1076,8 +1013,10 @@ export function ConfigureAmsSlotModal({ // Pre-select current profile when modal opens, reset when closes useEffect(() => { if (isOpen) { - // Pre-populate from saved preset mapping (most reliable) - if (slotInfo.savedPresetId) { + // The spool's own per-model preset first -- see the query above. + if (slotSpoolDefaults?.slicer_filament) { + setSelectedPresetId(slotSpoolDefaults.slicer_filament); + } else if (slotInfo.savedPresetId) { setSelectedPresetId(slotInfo.savedPresetId); } else if (slotInfo.trayInfoIdx && cloudSettings?.filament) { // Fallback: try to match by tray_info_idx in cloud presets @@ -1120,11 +1059,33 @@ export function ConfigureAmsSlotModal({ setShowSuccess(false); scrolledToRef.current = ''; } - }, [isOpen, slotInfo.savedPresetId, slotInfo.trayInfoIdx, slotInfo.trayColor, cloudSettings?.filament, builtinFilaments]); + }, [ + isOpen, + slotSpoolDefaults?.slicer_filament, + slotInfo.savedPresetId, + slotInfo.trayInfoIdx, + slotInfo.trayColor, + cloudSettings?.filament, + builtinFilaments, + ]); // Auto-select best matching K profile when preset changes useEffect(() => { if (matchingKProfiles.length > 0) { + // The profile the spool is configured with for THIS hotend, if it still + // exists on the printer. Ahead of the slot's live cali_idx, which is + // whatever was selected last rather than what the spool is set to. + if (slotSpoolDefaults?.cali_idx != null) { + const configured = findProfileByCaliIdx( + matchingKProfiles, + slotSpoolDefaults.cali_idx, + slotSpoolDefaults.extruder ?? slotInfo.extruderId, + ); + if (configured) { + setSelectedKProfile(configured); + return; + } + } // Prefer the currently-active K-profile, resolved against this slot's own // nozzle — the index alone is ambiguous across hotends. if (slotInfo.caliIdx != null && slotInfo.caliIdx > 0) { @@ -1141,7 +1102,14 @@ export function ConfigureAmsSlotModal({ } else { setSelectedKProfile(null); } - }, [selectedPresetId, matchingKProfiles, slotInfo.caliIdx, slotInfo.extruderId]); + }, [ + selectedPresetId, + matchingKProfiles, + slotSpoolDefaults?.cali_idx, + slotSpoolDefaults?.extruder, + slotInfo.caliIdx, + slotInfo.extruderId, + ]); // Escape key handler const handleKeyDown = useCallback((e: KeyboardEvent) => { diff --git a/frontend/src/components/SpoolFormModal.tsx b/frontend/src/components/SpoolFormModal.tsx index 12e5acd78..5f643f78b 100644 --- a/frontend/src/components/SpoolFormModal.tsx +++ b/frontend/src/components/SpoolFormModal.tsx @@ -3,25 +3,32 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { X, Loader2, Save, Beaker, Palette, Zap, Tag, Unlink } from 'lucide-react'; import { api, ApiError } from '../api/client'; -import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client'; +import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset, BuiltinFilament, SpoolmanBulkCreateResult, SpoolFilamentPresetInput, SpoolKProfileInput, SpoolmanFilamentEntry } from '../api/client'; import { Button } from './Button'; import { useToast } from '../contexts/ToastContext'; -import type { SpoolFormData, PrinterWithCalibrations, ColorPreset, SpoolFormMode } from './spool-form/types'; +import type { + CalibrationProfile, + ColorPreset, + PresetChoice, + PrinterWithCalibrations, + SpoolFormData, + SpoolFormMode, +} from './spool-form/types'; import { defaultFormData, validateForm, SPOOLMAN_LINKED_FIELDS } from './spool-form/types'; -import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, loadRecentColors, pairedOptions, parsePresetName, saveRecentColor, withCurrentValue } from './spool-form/utils'; +import { buildFilamentOptions, extractBrandsFromPresets, fetchPrinterCalibrations, findPresetOption, hotendKey, loadRecentColors, pairedOptions, parsePresetKey, parsePresetName, presetKey, saveRecentColor, withCurrentValue } from './spool-form/utils'; import { MATERIALS } from './spool-form/constants'; import { FilamentSection } from './spool-form/FilamentSection'; import { ColorSection } from './spool-form/ColorSection'; import { AdditionalSection } from './spool-form/AdditionalSection'; import { SpoolmanFilamentPicker } from './spool-form/SpoolmanFilamentPicker'; -import { PAProfileSection } from './spool-form/PAProfileSection'; +import { PrinterProfilesSection } from './spool-form/PrinterProfilesSection'; import { SpoolUsageHistory } from './SpoolUsageHistory'; import { invalidateInventoryLocations, invalidateSpoolAndLocationQueries, } from '../utils/inventoryQueries'; -type TabId = 'filament' | 'pa-profile'; +type TabId = 'filament' | 'appearance' | 'printers'; const CLEAR_TAG_PAYLOAD = { tag_uid: null, tray_uuid: null, tag_type: null, data_origin: null }; @@ -75,6 +82,7 @@ export function SpoolFormModal({ const [cloudAuthenticated, setCloudAuthenticated] = useState(false); const [loadingCloudPresets, setLoadingCloudPresets] = useState(false); const [cloudPresets, setCloudPresets] = useState([]); + const [orcaSettingIds, setOrcaSettingIds] = useState>(new Set()); const [presetInputValue, setPresetInputValue] = useState(''); // Spool catalog @@ -104,16 +112,31 @@ export function SpoolFormModal({ // PA Profile state const [fetchedCalibrations, setFetchedCalibrations] = useState([]); - const [selectedProfiles, setSelectedProfiles] = useState>(new Set()); - const [expandedPrinters, setExpandedPrinters] = useState>(new Set()); + // Whether the calibration fetch above is still running. Without it the + // Printers tab renders its "no printers configured" empty state while the + // printers are still being asked -- which reads as a wrong answer rather + // than as a wait, and the fetch is several round trips per machine. + const [loadingCalibrations, setLoadingCalibrations] = useState(false); + // One K profile per hotend, keyed `printerId:extruder:diameter`. A Map rather + // than a Set of composite keys because the Printers tab presents each hotend + // as a single-choice dropdown -- the shape makes "two profiles for one + // hotend" unrepresentable instead of relying on eviction logic to prevent it. + const [selectedProfiles, setSelectedProfiles] = useState>(new Map()); + // Per-printer-model preset overrides, keyed by `presetKey(model, diameter)`. + // Only the models the user has actually overridden are present; an absent + // entry means "inherit this spool's own preset", which is exactly what the + // backend cascade does with a missing row. + const [modelPresets, setModelPresets] = useState>(new Map()); + const [selectedGroupId, setSelectedGroupId] = useState(''); // Use prop if provided, otherwise use self-fetched data const resolvedCalibrations = printersWithCalibrations.length > 0 ? printersWithCalibrations : fetchedCalibrations; - // Count selected PA profiles for tab badge - const selectedProfileCount = selectedProfiles.size; + // Tab badge: everything the user has configured under Printers, K profiles + // and preset overrides alike, since both live on that tab now. + const selectedProfileCount = selectedProfiles.size + modelPresets.size; // Fetch Spoolman filament catalog when in Spoolman mode // retry:false — Spoolman may be intentionally disabled (400); don't flood the server @@ -169,6 +192,10 @@ export function SpoolFormModal({ const orcaPresets = orcaResult.status === 'fulfilled' ? orcaResult.value.presets : []; setCloudAuthenticated(bambuConnected || orcaConnected); setCloudPresets([...bambuPresets, ...orcaPresets]); + // The two clouds are merged into one list, so remember which ids came + // from Orca -- it is the only way the origin badge can tell them + // apart afterwards. + setOrcaSettingIds(new Set(orcaPresets.map(p => p.setting_id))); } catch (e) { if (cancelled) return; console.error('Failed to fetch cloud presets:', e); @@ -189,27 +216,40 @@ export function SpoolFormModal({ // Fetch printer calibrations if not provided via props if (printersWithCalibrations.length === 0) { (async () => { + setLoadingCalibrations(true); try { const printers = await api.getPrinters(); const statuses = await Promise.all( printers.map(p => api.getPrinterStatus(p.id).catch(() => null)), ); - const results: PrinterWithCalibrations[] = []; - for (let i = 0; i < printers.length; i++) { - const printer = printers[i]; - const status = statuses[i]; - const connected = status?.connected ?? false; - let calibrations: PrinterWithCalibrations['calibrations'] = []; - if (connected) { - // Fetch across every installed nozzle so dual-nozzle printers - // surface both the 0.4mm and 0.6mm K-profiles, not just 0.4 (#2618). - calibrations = await fetchPrinterCalibrations(printer.id, status); - } - results.push({ printer: { ...printer, connected }, calibrations }); - } - setFetchedCalibrations(results); + // Printers in parallel, diameters within a printer in series. + // Separate machines are separate MQTT connections and do not + // interfere; it is one printer's own firmware that drops a + // concurrent burst of calibration requests (see + // fetchPrinterCalibrations). Walking the fleet one machine at a + // time made the whole tab wait for the sum of every printer. + const results = await Promise.all( + printers.map(async (printer, i) => { + const status = statuses[i]; + const connected = status?.connected ?? false; + let calibrations: PrinterWithCalibrations['calibrations'] = []; + if (connected) { + // Across every nozzle size, so a profile for a size that is + // not currently fitted is still offered (#2618 fetched only + // the fitted ones). + calibrations = await fetchPrinterCalibrations(printer.id, status); + } + // Keep the reported nozzle hardware: the Printers tab lists a + // model's installed diameters from it. Read as a set of + // diameters only -- never indexed by extruder. + return { printer: { ...printer, connected }, calibrations, nozzles: status?.nozzles }; + }), + ); + if (!cancelled) setFetchedCalibrations(results); } catch (e) { console.error('Failed to fetch printer calibrations:', e); + } finally { + if (!cancelled) setLoadingCalibrations(false); } })(); } @@ -228,8 +268,8 @@ export function SpoolFormModal({ // Build filament options: cloud → local → fallback const filamentOptions = useMemo( - () => buildFilamentOptions(cloudPresets, new Set(), localPresets, builtinFilaments), - [cloudPresets, localPresets, builtinFilaments], + () => buildFilamentOptions(cloudPresets, new Set(), localPresets, builtinFilaments, orcaSettingIds), + [cloudPresets, localPresets, builtinFilaments, orcaSettingIds], ); // Extract brands from presets @@ -374,22 +414,34 @@ export function SpoolFormModal({ }); setPresetInputValue(spool.slicer_filament_name || spool.slicer_filament || ''); - // Load K-profiles for this spool + // Load K-profiles for this spool. The stored row carries everything + // the picker needs to show the selection before the printer answers, + // so an offline printer still renders what was chosen for it. if (spool.k_profiles && spool.k_profiles.length > 0) { - const profileKeys = new Set(); + const chosen = new Map(); for (const p of spool.k_profiles) { - if (p.cali_idx !== null && p.cali_idx !== undefined) { - profileKeys.add(`${p.printer_id}:${p.cali_idx}:${p.extruder ?? 'null'}`); - } + if (p.cali_idx === null || p.cali_idx === undefined) continue; + const diameter = (p.nozzle_diameter || '').trim() || '0.4'; + const extruder = p.extruder ?? 0; + chosen.set(hotendKey(p.printer_id, extruder, diameter), { + cali_idx: p.cali_idx, + filament_id: '', + setting_id: p.setting_id || '', + name: p.name || '', + k_value: p.k_value, + n_coef: 0, + extruder_id: extruder, + nozzle_diameter: diameter, + }); } - setSelectedProfiles(profileKeys); + setSelectedProfiles(chosen); } else { - setSelectedProfiles(new Set()); + setSelectedProfiles(new Map()); } } else { setFormData(defaultFormData); setPresetInputValue(''); - setSelectedProfiles(new Set()); + setSelectedProfiles(new Map()); } // Reset on every open, not just the create path (#1905). The modal keeps // its state while closed, and the Quick Add toggle only renders in create @@ -400,11 +452,51 @@ export function SpoolFormModal({ setQuantity(1); setErrors({}); setActiveTab('filament'); + setSelectedGroupId(''); + // Cleared on every open, both branches: the modal keeps its state while + // closed, so editing spool B after spool A would otherwise show (and + // save) A's per-model overrides on B. Refilled by the fetch below. + setModelPresets(new Map()); setWeightTouched(false); setLocationIdTouched(false); } }, [isOpen, spool, mode, isCopying]); + // Load this spool's per-printer-model preset overrides. Fetched rather than + // read off the spool: they are deliberately not embedded in the spool + // response, which the inventory list returns once per spool the user owns. + // Copying a spool copies its overrides -- they describe the filament on the + // spool, which is what a copy has too. + useEffect(() => { + if (!isOpen || !spool) return; + let cancelled = false; + const load = spoolmanMode ? api.getSpoolmanFilamentPresets : api.getSpoolFilamentPresets; + load(spool.id) + .then(rows => { + if (cancelled) return; + const next = new Map(); + for (const row of rows) { + next.set(presetKey(row.printer_model, row.nozzle_diameter || ''), { + code: row.slicer_filament || '', + name: row.slicer_filament_name || '', + }); + } + setModelPresets(next); + }) + .catch(e => { + // Non-fatal: the tab still works, it just starts with nothing + // overridden. Saving from that state WOULD clear the stored rows, so + // say so rather than letting the user save over what they cannot see. + if (cancelled) return; + console.error('Failed to load filament preset overrides:', e); + showToast(t('inventory.filamentPresetsLoadFailed'), 'warning'); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, spool?.id, spoolmanMode]); + // Legacy rows may have storage_location text but no location_id yet — link when catalog loads. useEffect(() => { if (!isOpen || !spool || locationIdTouched || formData.location_id != null) return; @@ -416,12 +508,6 @@ export function SpoolFormModal({ } }, [isOpen, spool, storageLocations, formData.location_id, locationIdTouched]); - // Expand all printers in PA profile section when calibrations are available - useEffect(() => { - if (isOpen && resolvedCalibrations.length > 0) { - setExpandedPrinters(new Set(resolvedCalibrations.map(p => String(p.printer.id)))); - } - }, [isOpen, resolvedCalibrations]); // Update field helper const updateField = (key: K, value: SpoolFormData[K]) => { @@ -481,7 +567,7 @@ export function SpoolFormModal({ : api.createSpool(data as Parameters[0]), onSuccess: async (newSpool) => { if (newSpool?.id) { - const ok = await saveKProfiles(newSpool.id); + const ok = await savePrinterProfiles(newSpool.id); if (!ok) return; } await refreshSpoolQueries(); @@ -518,9 +604,12 @@ export function SpoolFormModal({ ? spoolmanResult.created : (result as InventorySpool[]); - if (selectedProfiles.size > 0) { + // Bulk create: every copy gets the same profiles and overrides. Skipped + // entirely when the user configured neither, so a plain bulk add does + // not fire two writes per spool. + if (selectedProfiles.size > 0 || modelPresets.size > 0) { for (const s of createdSpools) { - await saveKProfiles(s.id); + await savePrinterProfiles(s.id); } } await refreshSpoolQueries(); @@ -554,7 +643,7 @@ export function SpoolFormModal({ : api.updateSpool(spool!.id, data as Parameters[1]), onSuccess: async () => { if (spool?.id) { - const ok = await saveKProfiles(spool.id); + const ok = await savePrinterProfiles(spool.id); if (!ok) return; } await refreshSpoolQueries(); @@ -625,6 +714,18 @@ export function SpoolFormModal({ queryFn: api.getSettings, enabled: isOpen, }); + + // Backend Bambu printer-model registry, so the Printers tab can read the + // model out of a preset name and offer each model only its own presets. The + // same query key and staleTime the Configure AMS Slot modal uses -- the + // registry only changes across backend releases, so this is a cache hit + // whenever that modal has been opened. + const { data: printerModelsData } = useQuery({ + queryKey: ['slicerPrinterModels'], + queryFn: api.getSlicerPrinterModels, + enabled: isOpen, + staleTime: Infinity, + }); const availableCategories = (() => { const set = new Set(); for (const s of allSpools ?? []) { @@ -660,65 +761,61 @@ export function SpoolFormModal({ }, }); - // Save K-profiles for selected calibrations. Returns false if any error occurred. - const saveKProfiles = async (spoolId: number): Promise => { - const saveApi = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles; - - if (selectedProfiles.size === 0) { - try { - await saveApi(spoolId, []); - return true; - } catch (e) { - console.error('Failed to save K-profiles:', e); - showToast(t('inventory.kProfileSaveFailed'), 'warning'); - return false; - } - } + // Save everything the Printers tab holds: one K profile per hotend and the + // per-printer-model preset overrides. Returns false if either write failed, + // which keeps the modal open so the user does not lose what they picked. + const savePrinterProfiles = async (spoolId: number): Promise => { + const saveKApi = spoolmanMode ? api.saveSpoolmanKProfiles : api.saveSpoolKProfiles; + const savePresetApi = spoolmanMode ? api.saveSpoolmanFilamentPresets : api.saveSpoolFilamentPresets; + // The selection Map is keyed by hotend and holds the calibration itself, + // so nothing has to be resolved back out of the printer's live list. That + // also fixes a real defect in the old key-based lookup: it matched a + // calibration by cali_idx alone, and cali_idx is numbered PER NOZZLE -- + // on a dual-nozzle printer it could resolve the other hotend's entry and + // persist that entry's K value and diameter. const profiles: SpoolKProfileInput[] = []; - let dropped = 0; - for (const key of selectedProfiles) { - const [printerIdStr, caliIdxStr, extruderStr] = key.split(':'); - const printerId = parseInt(printerIdStr); - const caliIdx = parseInt(caliIdxStr); - const extruder = extruderStr === 'null' ? 0 : parseInt(extruderStr); - - const pc = resolvedCalibrations.find(p => p.printer.id === printerId); - if (pc) { - const cal = pc.calibrations.find(c => c.cali_idx === caliIdx); - if (cal) { - profiles.push({ - printer_id: printerId, - extruder, - nozzle_diameter: cal.nozzle_diameter || '0.4', - k_value: cal.k_value, - name: cal.name || null, - cali_idx: cal.cali_idx, - setting_id: cal.setting_id || null, - }); - } else { - dropped++; - } - } else { - dropped++; - } + for (const [key, cal] of selectedProfiles) { + const [printerIdStr, extruderStr, diameter] = key.split(':'); + profiles.push({ + printer_id: parseInt(printerIdStr), + extruder: parseInt(extruderStr), + nozzle_diameter: diameter || '0.4', + k_value: cal.k_value, + name: cal.name || null, + cali_idx: cal.cali_idx, + setting_id: cal.setting_id || null, + }); } - if (dropped > 0) { - console.error(`saveKProfiles: ${dropped} profile key(s) could not be resolved`, Array.from(selectedProfiles)); + const presets: SpoolFilamentPresetInput[] = []; + for (const [key, choice] of modelPresets) { + const { model, diameter } = parsePresetKey(key); + if (!model) continue; + presets.push({ + printer_model: model, + nozzle_diameter: diameter, + slicer_filament: choice.code || null, + slicer_filament_name: choice.name || null, + }); + } + + // Both are full replacements, so both run even when empty -- that is how + // the user clears the last profile or the last override. + try { + await saveKApi(spoolId, profiles); + } catch (e) { + console.error('Failed to save K-profiles:', e); showToast(t('inventory.kProfileSaveFailed'), 'warning'); return false; } - if (profiles.length > 0) { - try { - await saveApi(spoolId, profiles); - return true; - } catch (e) { - console.error('Failed to save K-profiles:', e); - showToast(t('inventory.kProfileSaveFailed'), 'warning'); - return false; - } + try { + await savePresetApi(spoolId, presets); + } catch (e) { + console.error('Failed to save filament preset overrides:', e); + showToast(t('inventory.filamentPresetSaveFailed'), 'warning'); + return false; } return true; @@ -800,7 +897,12 @@ export function SpoolFormModal({ onClick={onClose} /> -
+ {/* Wider than the old max-w-xl: the Printers tab is a model list beside + a detail pane holding a preset row per nozzle size and a hotend-by- + size grid, which needs room for both. Held constant across tabs + rather than sized per tab -- a modal that resizes as you switch tabs + reads as a layout bug. */} +
{/* Header */}

@@ -857,20 +959,33 @@ export function SpoolFormModal({ : 'text-bambu-gray hover:text-white' }`} > - + {t('inventory.filamentInfoTab')} {!quickAdd && ( + )} + {!quickAdd && ( +

+
+ ) : activeTab === 'appearance' ? ( +
{/* Color Section */}

@@ -981,14 +1099,18 @@ export function SpoolFormModal({ )}

) : ( - )}
diff --git a/frontend/src/components/spool-form/AdditionalSection.tsx b/frontend/src/components/spool-form/AdditionalSection.tsx index 67a383872..b1e75187f 100644 --- a/frontend/src/components/spool-form/AdditionalSection.tsx +++ b/frontend/src/components/spool-form/AdditionalSection.tsx @@ -204,19 +204,26 @@ export function AdditionalSection({ }, [isRemainingFocused, remainingWeight]); return ( -
+ // Two columns from sm up. These are all short single-value fields, and at + // the form's width one per row left most of each row empty and pushed the + // rest below the fold. The two that stay full width earn it: the spool + // catalogue picker carries a long product name beside its own number + // input, and the note is a textarea. +
{/* Empty Spool Weight — hidden in Spoolman mode (managed per filament type in Spoolman) */} - {spoolmanMode ? ( -

{t('inventory.spoolWeightManagedBySpoolman')}

- ) : ( - updateField('core_weight', weight)} - catalogId={formData.core_weight_catalog_id} - onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)} - /> - )} +
+ {spoolmanMode ? ( +

{t('inventory.spoolWeightManagedBySpoolman')}

+ ) : ( + updateField('core_weight', weight)} + catalogId={formData.core_weight_catalog_id} + onCatalogIdChange={(id) => updateField('core_weight_catalog_id', id)} + /> + )} +
{/* Current Weight (remaining filament) */}
@@ -372,17 +379,6 @@ export function AdditionalSection({

- {/* Note */} -
- -