mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Let a filled or foamed filament keep its own name (issue #2902)
The reduction that gave an AMS slot a material type read PLA-AERO,
PLA-GF, ASA-GF and PPS-GF as their base material, so a slot loaded with
foaming or glass-filled filament went out saying plain PLA or ASA. That
is worse than the bug it replaced. "PLA-AERO" matched nothing before,
which was useless but honest; "PLA" matches every PLA plate in the
queue, so the dispatcher would have sent one to filament that will not
print it -- and the contract the first fix claimed, that it could only
ever repair a slot, no longer held. @doncaruana caught PLA Aero on the
issue.
All four are values Bambuddy itself offers: filament_fields.json is the
material list the Profiles editor puts in a dropdown, and the reduction
table was assembled from the cloud filament names and the frontend
preset parser without ever being checked against it. It is checked now,
so the next type added to one and not the other fails a test rather than
a print. ASA-AERO joins them from the cloud catalogue (GFB02).
The table hyphenates because the slicers do, while a spool says "PLA
Aero" and every Bambu preset name says "Bambu PLA Aero". Adjacent words
are joined and taken when the join is a type exactly -- exactly, because
letting the prefix and suffix rules reach across a space would make
"Support for PLA" a type by its tail.
Also from @doncaruana, and the better half of his point: a preset is
chosen from a list the slicer defines, so it already knows its own type
and nothing has to be read out of a product name. The resolver now hands
that answer back and both assign routes prefer it. It cannot be the only
source -- material is required on a spool and slicer_filament is not, and
the spool this issue was reported for had no preset at all -- so the
reduction stays as the fallback for spools without one.
Two things had to move with it. The auto-unlink guard compared the
slot's reported type against the reduced material, so a spool whose
preset outranked its material column would have been unlinked from the
slot it had just been assigned to; it now accepts any type the assign
path could have written. And two lookups keyed by material took the
catch-all for a type they had no row for, which sent an ASA-GF spool out
at 200/240 -- too cold to extrude -- and preheated its chamber to
nothing. Both fall back to the base material last, so PLA-CF, PETG-CF
and PA-CF keep the rows they are listed with, and ASA-CF and ABS-GF pick
up ranges they had been missing all along.
What counts as a material name is decided by the base for the same
reason: saying yes throws the value away and rescues the slot from the
generic-material fallback, so the answer has to be no when that fallback
has nothing to offer. ABS-GF reduces to a generic ABS the printer can
resolve; PPS-CF reduces to nothing and is left as it stands. Adding a
type to the table therefore cannot quietly change that answer, which is
how these five slipped through in the first place.
------
Hand the bundled chamber-preheat table back the way it is read
Every lookup of the per-filament chamber map happens after the keys are
upper-cased, and the parser documents exactly that: keys uppercased,
DEFAULT always present so the resolution loop can index it
unconditionally. The three fallback paths returned the bundled constant
as declared, with the lowercase "default" row the Settings editor writes
and displays, so an install that had never opened the setting got a dict
the loop could not read its fallback out of and used a hardcoded 0 for
any filament without a row of its own.
It reported the right number only because that bundled default is 0.
Raising it would have changed nothing for everyone who had not
customised the map, with the map in Settings still showing the value
that was not being used.
The test that should have caught this asserted the fallback under either
spelling, and its docstring contradicted itself between title and
comment. It pins the contract now, over all four ways the parser can
fall back.
This commit is contained in:
+2
-1
@@ -19,10 +19,11 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **The Watchtower we recommend for daily builds is the maintained fork (#2917, reported by @CamelT0E)** — The daily-build instructions in the README, on Docker Hub and in every daily prerelease pointed at containrrr.dev/watchtower. That project has been archived and read-only since December 2025 and its last release, v1.7.1, is from November 2023, so anyone following the recommendation was being handed a container with Docker socket access that had not received a fix in over two years. Development continues in Nicholas Fedor's fork, which ships as `nickfedor/watchtower` and released v1.21.0 this month. All four references now point at watchtower.nickfedor.com and name the image, including the release-notes template in `docker-publish-daily-beta.sh` that produced the screenshot in the report — the READMEs alone would have left every future daily prerelease repeating the dead link. Existing images keep working; only the recommendation changed.
|
||||
|
||||
### Fixed
|
||||
- **The bundled chamber-preheat table was unreadable to the code that reads it** — every lookup of the per-filament chamber map happens after the keys are upper-cased, but an install that had never opened the setting got the bundled table back exactly as declared, with its lowercase `default` row. The scheduler then looked for `DEFAULT`, found nothing, and used a hardcoded 0 for any filament without a row of its own. It reported the right number only because that bundled default is 0 — raising it would have silently changed nothing for everyone who had not customised the map. Both paths out of the parser now honour the one contract it documents.
|
||||
- **The archives API never reported which plate was printed (#2796, contributed by @sgiffhorn)** — `archive_to_response()` builds the archives response field by field and had no line for `plate_id`, so `GET /archives/`, the detail endpoint, search, PATCH and the project archive list all answered `plate_id: null` — for archives whose column was populated as well. `ArchiveResponse.plate_id` defaults to `None`, so Pydantic filled the null in without complaint and nothing ever raised. The column has been written since #2603 and is backfilled from the queue on startup, so the plate was recorded all along and simply could not be read back out; on the reporting instance 181 of 257 rows carry one. Archive cards now name the plate again — but only when it is not the first one. The queue records a plate for single-plate files too, because the print dialog auto-selects the only plate there is, so labelling every archive that has a `plate_id` would have put "Plate 1" on most cards in the grid and taken room from the truncated print name. A multi-plate archive printed from its first plate still identifies itself through the plate carousel, which has always gated on whether the source 3MF holds more than one plate.
|
||||
- **The first AMS sync after enabling Spoolman from Settings failed on every slot (#2903, diagnosed by @ojimpo)** — Spoolman rejects a spool whose `extra` dict carries a key it has not been told about, answering HTTP 400 `Unknown extra field tag.`. Bambuddy stores the tray UUID in `extra.tag`, so that key has to be registered before the first spool is created — and registration only ever ran from three hand-maintained lists that fire when the integration is *set up*: the Connect button, application startup, and two inline blocks in the inventory routes. Enabling Spoolman from the Settings page reaches none of them, so "Sync AMS Data" reported `Synced 0 spools with 3 errors` with vendor and filament creation succeeding and only spool creation rejected. Restarting Bambuddy cleared it, which made the failure look like a connectivity problem. The Connect button would also have fixed it, but it is not on screen by then: saving the settings initialises the Spoolman client as a side effect of syncing locations, the status endpoint reads any live client as "connected", and the UI shows the Connect button only while disconnected — so the one registration path reachable from the interface hides itself exactly when it is needed. Registration now travels with the write instead of the feature: every spool write that carries an `extra` dict registers the keys it is about to send, once per client, before it sends them. That covers all five places Bambuddy writes a tag — AMS sync, linking and unlinking a tag from a spool, and both inventory edit paths — and it closes the class rather than the instance, since a write that carries a key is now a write that registers it. `bambu_color_name` is the cautionary case: it never made it into the Connect or startup lists at all, and worked only because two call sites remembered to register it by hand. Registration stays best-effort — if it fails, the write is still attempted and reports exactly what it reported before, and the failure is not cached, so a Spoolman that was merely restarting is retried on the next write.
|
||||
- **The Spoolman connection status described Bambuddy's memory rather than Spoolman (#2903)** — "Connected" meant "some earlier request in this process left a client object behind", and around twenty code paths build one lazily, so the answer turned on which page had been opened rather than on anything about Spoolman. A switched-off integration could still report Connected from a leftover client; changing the URL kept reporting on the previous host; and saving the Settings page built a client as a side effect of syncing locations, which is how enabling Spoolman came to report Connected before anything had been set up. The status now asks the Spoolman that is configured right now, so the answer is the same whatever you opened first. The Disconnect button is gone with it: Spoolman is a stateless HTTP API with no session to close, so the button only dropped the client object that the next request rebuilt moments later, after which the status flipped back on its own — it looked like it had worked, then quietly undid itself. Turning the integration off is the enable toggle's job, and Connect remains as what it actually is, a way to re-check a Spoolman that is not answering.
|
||||
- **An AMS slot assigned a PLA+ spool became unusable for PLA (#2902, reported by @doncaruana)** — Assigning a spool wrote its material straight into the slot's `tray_type`, and a slot that says "PLA+" satisfies nothing that asks for PLA: not OrcaSlicer, not Bambu Studio, and not Bambuddy's own dispatch matcher, which compares the printer's reported type to the one the 3MF declares. The same string then missed the filament-id lookup, so the slot went out with no `tray_info_idx` at all — the half-configured state a printer reverts from — and took the generic 200/240°C nozzle range instead of PLA's. PLA+ is not a special case: Bambuddy's own colour catalogue supplies the material dropdown, and about forty of its values are vendor product lines rather than filament types — HTPLA, PolyTerra PLA, PLA Matte, ASA Extrafill, Flexfill TPU 98A. All four routes that configure a slot now reduce the material to a type the printer knows before sending it, and the product name moves to `tray_sub_brands`, which is where Bambu Lab itself puts it — their catalogue has a preset named "eSUN PLA+" whose type is PLA. A material that cannot be placed is sent exactly as before rather than guessed at, so this can only repair a slot, never break a working one; and a material that already had a preset of its own keeps it, so "PETG HF" is not quietly downgraded to plain PETG. One thing that starts working as a result: a slot holding a calibrated preset is now reused when a same-material spool is assigned to it, which for these spools could never happen before.
|
||||
- **An AMS slot assigned a PLA+ spool became unusable for PLA (#2902, reported by @doncaruana)** — Assigning a spool wrote its material straight into the slot's `tray_type`, and a slot that says "PLA+" satisfies nothing that asks for PLA: not OrcaSlicer, not Bambu Studio, and not Bambuddy's own dispatch matcher, which compares the printer's reported type to the one the 3MF declares. The same string then missed the filament-id lookup, so the slot went out with no `tray_info_idx` at all — the half-configured state a printer reverts from — and took the generic 200/240°C nozzle range instead of PLA's. PLA+ is not a special case: Bambuddy's own colour catalogue supplies the material dropdown, and about forty of its values are vendor product lines rather than filament types — HTPLA, PolyTerra PLA, PLA Matte, ASA Extrafill, Flexfill TPU 98A. All four routes that configure a slot now reduce the material to a type the printer knows before sending it, and the product name moves to `tray_sub_brands`, which is where Bambu Lab itself puts it — their catalogue has a preset named "eSUN PLA+" whose type is PLA. A material that cannot be placed is sent exactly as before rather than guessed at, so this can only repair a slot, never break a working one; and a material that already had a preset of its own keeps it, so "PETG HF" is not quietly downgraded to plain PETG. One thing that starts working as a result: a slot holding a calibrated preset is now reused when a same-material spool is assigned to it, which for these spools could never happen before. A filled or foamed variant is a type in its own right and is left alone: PLA-AERO, PLA-GF, ASA-GF and PPS-GF were being reduced onto their base material, so a plain PLA plate could have been dispatched onto foaming filament — every type the Profiles editor offers now reaches the slot intact, whether it is written "PLA-AERO" or "PLA Aero". And when a spool points at a slicer preset, the slot takes that preset's own filament type rather than one read out of the material column, since a preset is chosen from a list the slicer defines; the material is still what a spool without a preset is read from. Two lookups keyed by material had to learn the same distinction: a variant with no nozzle range of its own now takes its base material's rather than the 200/240 catch-all, so an ASA-GF spool is no longer sent out at ASA's minimum minus thirty degrees, and the preheat chamber target does the same — ASA-CF and ABS-GF reach a warm chamber for the first time, while PETG-CF and PA-CF keep the hotter rows they are listed with.
|
||||
- **A failed upload told you to check the SD card, whatever had actually gone wrong (#2899, reported by @grolmus)** — Every dispatch upload that failed carried the same sentence: "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)." The reporter got it after a TLS handshake failure and restarted the printer on the strength of it. That could not have helped — the handshake never reached the printer's filesystem, and the cool-off that made the next dispatch fail identically lives in Bambuddy's own memory, where power-cycling a printer does not reach. #2780 had already taken operator advice out of this failure's log line, for exactly the reason that the advice was known not to work; it survived in the string people actually read. The information to say something true was never missing. The FTP client separates five connect failures and three upload reply codes, each with its own log line — 553 even gets a spelled-out list of storage causes — and then handed the caller a bare true-or-false, so the dispatch had nothing to go on and guessed storage for all of them. The reason now travels with the result, and the message is chosen from it: a 553 or 552 keeps the card advice, which is the case it was written for, and quotes the printer's reply code so a queue entry and a support bundle can be lined up. A handshake failure says the file service answered without TLS and that the card is not involved. A refused connection points at the access code, a timeout at the network, and anything the client could not classify says so and points at the log rather than picking a plausible cause — a wrong instruction costs more than a vague one, because it sends someone to work on hardware that is fine. No message prescribes a power cycle, which is the restraint #2780 settled on. The failure notification carries the same sentence the queue shows, rather than its own fixed "Failed to upload file to printer", so a push and the screen can no longer disagree.
|
||||
- **One handshake failure took out three queued jobs and every retry they had (#2898, reported by @grolmus)** — A print dispatch that met a TLS handshake failure spent its whole retry budget without opening a single socket. The cool-off that a failed handshake arms (#2780) lives inside `connect()`, so it applied to everything — including the dispatch, whose four attempts two seconds apart were all answered from the gate rather than the network. The reporter's farm logs the shape exactly: the delete that clears the way for the upload took the SSL error at 11:10:04.956, the upload's first attempt started 8ms later, and attempts two through four took one to two milliseconds each. Because the cool-off runs for five minutes, the next two jobs queued for that printer failed the same way inside the same window, and on that farm the failure is transient — a manual connect a second later completes cleanly — so the retry the gate suppressed is the one that would have worked. The gate was serving two callers that want opposite things from it. The background sweeps that fetch a 3MF, a cover or a timelapse after a print walk about a hundred and ten candidate paths against one wedged printer with nobody waiting, and backing off for minutes is right for them; they keep today's behaviour untouched. A dispatch is one delete plus at most four upload attempts with someone watching a progress bar, so it now ignores the cool-off, as does a firmware upload, for the same reason. Callers that do respect the cool-off no longer sleep out a retry loop against it either: the loop stops at the attempt that armed the gate and says so, instead of spending three more attempts and six seconds on connections that cannot happen. What made this a log dive rather than a glance is fixed with it: the cool-off skip was the one connect failure that reported without naming its cause, and did so at debug level, so four identical reason-free warnings were all the operator saw. It now says at warning level that nothing was sent and how long the printer has left — once per cool-off rather than once per attempt, so that raising it does not recreate the log flood #2780 set out to stop.
|
||||
- **Archived projects crowded out the live ones in every project picker (#2888, reported by @e77)** — The reporter files each job under its own project and archives it when the job is done, so five active projects sat behind thirty-odd finished ones in the Project dropdown of the Edit Archive dialog — an unscrolled list of everything ever created, with no way to tell which entries were still live. That dropdown, the one on the pending-uploads panel, the bulk "Add to Project" dialog and the File Manager's folder link now leave archived projects out. Completed projects stay: a project marked completed says the work is done, not that it should be hidden, and filing a reprint under one is ordinary. The Archives right-click submenu had gone the other way and offered active projects only, so a completed project was reachable from the Edit dialog and not from the menu beside it; all five surfaces now apply the same rule. Whatever an archive is already filed under stays on its own list whatever its status — a `<select>` holding a value none of its options match is reset by the browser to the first one, which here reads "No project", so an archive in an archived project would have stated that it was filed nowhere. The one picker deliberately left alone is the parent-project picker, where a finished or archived project is still a legal parent. Fixed alongside it: the status tabs on the Projects page counted only the projects the selected filter had already let through, so every tab but the current one counted zero and lost its badge entirely — the reporter's own screenshot shows "Active 5" beside a bare Completed and a bare Archived, with thirty projects behind them. They are counted from the whole fleet now. Covered by frontend tests.
|
||||
|
||||
@@ -63,11 +63,10 @@ from backend.app.services.spool_csv import (
|
||||
from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client
|
||||
from backend.app.utils.filament_ids import (
|
||||
GENERIC_FILAMENT_IDS,
|
||||
MATERIAL_TEMPS,
|
||||
filament_id_to_setting_id,
|
||||
normalize_slicer_filament,
|
||||
)
|
||||
from backend.app.utils.filament_types import is_material_name, printer_filament_type
|
||||
from backend.app.utils.filament_types import is_material_name, nozzle_temp_range, printer_filament_type
|
||||
from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -136,7 +135,7 @@ async def apply_spool_to_slot_via_mqtt(
|
||||
# the builtin-name realignment, AND the defensive PFUS/PFCN/material-name
|
||||
# sanitization. When it returns an empty tray_info_idx the local
|
||||
# current-tray-state + generic-material fallback below rescues the slot.
|
||||
tray_info_idx, setting_id, sub_brand_override = await resolve_slicer_filament(
|
||||
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,
|
||||
@@ -145,6 +144,11 @@ async def apply_spool_to_slot_via_mqtt(
|
||||
)
|
||||
if sub_brand_override:
|
||||
tray_sub_brands = sub_brand_override
|
||||
# A preset says what its material is; the reduction above only infers it
|
||||
# from whatever wording the spool's material column happens to carry. When
|
||||
# the spool has a preset, its answer wins (issue #2902, @doncaruana).
|
||||
if type_override:
|
||||
tray_type = printer_filament_type(type_override)
|
||||
|
||||
if not tray_info_idx:
|
||||
if (
|
||||
@@ -187,9 +191,7 @@ async def apply_spool_to_slot_via_mqtt(
|
||||
# Same order as the generic-id lookup above: the spool's own wording wins,
|
||||
# the reduced type rescues what it does not cover. Without the second
|
||||
# lookup a PLA+ spool took the 200/240 catch-all instead of PLA's 190/230.
|
||||
temp_min, temp_max = (
|
||||
MATERIAL_TEMPS.get((spool.material or "").upper()) or MATERIAL_TEMPS.get(tray_type.upper()) or (200, 240)
|
||||
)
|
||||
temp_min, temp_max = nozzle_temp_range(spool.material, tray_type)
|
||||
if spool.nozzle_temp_min is not None:
|
||||
temp_min = spool.nozzle_temp_min
|
||||
if spool.nozzle_temp_max is not None:
|
||||
|
||||
@@ -32,10 +32,9 @@ from backend.app.services.spoolman import (
|
||||
)
|
||||
from backend.app.utils.filament_ids import (
|
||||
GENERIC_FILAMENT_IDS,
|
||||
MATERIAL_TEMPS,
|
||||
normalize_slicer_filament,
|
||||
)
|
||||
from backend.app.utils.filament_types import printer_filament_type
|
||||
from backend.app.utils.filament_types import nozzle_temp_range, printer_filament_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -970,9 +969,7 @@ async def link_spool(
|
||||
or ""
|
||||
)
|
||||
setting_id = ""
|
||||
temp_defaults = (
|
||||
MATERIAL_TEMPS.get(material_upper) or MATERIAL_TEMPS.get(tray_type.upper()) or (200, 240)
|
||||
)
|
||||
temp_defaults = nozzle_temp_range(material, tray_type)
|
||||
temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
|
||||
temp_max = temp_defaults[1]
|
||||
|
||||
|
||||
@@ -61,11 +61,10 @@ from backend.app.services.spoolman import (
|
||||
from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_slot
|
||||
from backend.app.utils.filament_ids import (
|
||||
GENERIC_FILAMENT_IDS,
|
||||
MATERIAL_TEMPS,
|
||||
filament_id_to_setting_id,
|
||||
normalize_slicer_filament,
|
||||
)
|
||||
from backend.app.utils.filament_types import printer_filament_type
|
||||
from backend.app.utils.filament_types import nozzle_temp_range, printer_filament_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1505,7 +1504,7 @@ async def assign_spoolman_slot(
|
||||
# configured profile never reached the printer. Shared with the
|
||||
# internal-mode route via the same helper so the two flows can't
|
||||
# drift again.
|
||||
tray_info_idx, setting_id, sub_brand_override = await resolve_slicer_filament(
|
||||
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"),
|
||||
@@ -1514,6 +1513,11 @@ async def assign_spoolman_slot(
|
||||
)
|
||||
if sub_brand_override:
|
||||
tray_sub_brands = sub_brand_override
|
||||
# A preset carries its own type; the reduction above only infers
|
||||
# one from Spoolman's free-text material. The preset wins when the
|
||||
# spool has one (issue #2902, @doncaruana).
|
||||
if type_override:
|
||||
tray_type = printer_filament_type(type_override)
|
||||
|
||||
material_upper = material.upper().strip()
|
||||
# Fall back to generic-material id when slicer_filament is empty
|
||||
@@ -1539,7 +1543,7 @@ async def assign_spoolman_slot(
|
||||
if tray_info_idx and not setting_id:
|
||||
setting_id = filament_id_to_setting_id(tray_info_idx)
|
||||
|
||||
temp_defaults = MATERIAL_TEMPS.get(material_upper) or MATERIAL_TEMPS.get(tray_type.upper()) or (200, 240)
|
||||
temp_defaults = nozzle_temp_range(material, tray_type)
|
||||
temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
|
||||
temp_max = temp_defaults[1]
|
||||
|
||||
|
||||
+34
-2
@@ -2114,10 +2114,42 @@ async def on_ams_change(printer_id: int, ams_data: list):
|
||||
spool = assignment.spool
|
||||
if spool:
|
||||
spool_color = (spool.rgba or "FFFFFFFF").upper()
|
||||
spool_type = printer_filament_type(spool.material).upper()
|
||||
# Two ways the assign path can have arrived at the
|
||||
# slot's type, so both count as "we wrote this".
|
||||
# The material column is one; the spool's preset is
|
||||
# the other, and it outranks the material when the
|
||||
# spool has one -- a spool whose material says PLA
|
||||
# and whose preset is "Bambu PLA Aero" puts
|
||||
# PLA-AERO in the slot (#2902). Read from the stored
|
||||
# preset name rather than resolving the preset,
|
||||
# because this runs on every AMS push and a cloud
|
||||
# lookup here would be both slow and unavailable on
|
||||
# the unauthenticated replay path.
|
||||
spool_types = {printer_filament_type(spool.material).upper()}
|
||||
if spool.slicer_filament_name:
|
||||
spool_types.add(printer_filament_type(spool.slicer_filament_name).upper())
|
||||
# An imported local preset stores its type outright,
|
||||
# which is what the assign path used -- and the name
|
||||
# above may be unset. One keyed read, and only on a
|
||||
# mismatch, which is rare.
|
||||
#
|
||||
# slicer_filament is free text up to fifty characters,
|
||||
# so the digits have to be checked against the range
|
||||
# of the integer primary key they are about to be
|
||||
# compared with. Postgres raises on an out-of-range
|
||||
# integer rather than simply not matching, and that
|
||||
# would poison this session and abandon the rest of
|
||||
# the cleanup pass.
|
||||
lp_ref = (spool.slicer_filament or "").strip()
|
||||
if lp_ref.isdigit() and int(lp_ref) <= 2147483647:
|
||||
from backend.app.models.local_preset import LocalPreset as _LP
|
||||
|
||||
lp_type = await db.scalar(select(_LP.filament_type).where(_LP.id == int(lp_ref)))
|
||||
if lp_type:
|
||||
spool_types.add(printer_filament_type(lp_type).upper())
|
||||
if (
|
||||
_colors_similar(cur_color, spool_color)
|
||||
and printer_filament_type(cur_type).upper() == spool_type
|
||||
and printer_filament_type(cur_type).upper() in spool_types
|
||||
):
|
||||
logger.info(
|
||||
"Auto-unlink: spool %d AMS%d-T%d — fingerprint mismatch but tray matches spool, updating fp",
|
||||
|
||||
@@ -4476,6 +4476,22 @@ class PrintScheduler:
|
||||
"default": 0,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _bundled_preheat_targets(cls) -> dict[str, int]:
|
||||
"""The bundled map under the same key casing a parsed one gets.
|
||||
|
||||
The constant is declared with a lowercase ``default`` because that is
|
||||
the key the Settings editor writes and displays. Every read of the map
|
||||
happens after ``str(key).upper()``, so handing the constant back as
|
||||
declared broke the contract the parser documents: an install that had
|
||||
never touched the setting returned a dict with no ``DEFAULT`` in it,
|
||||
and the resolution loop's fallback silently found nothing. It read the
|
||||
right number only because the bundled default happens to be 0 -- change
|
||||
that constant and every unconfigured install would keep preheating to
|
||||
zero with no way to tell why.
|
||||
"""
|
||||
return {key.upper(): value for key, value in cls.DEFAULT_PREHEAT_FILAMENT_TARGETS.items()}
|
||||
|
||||
async def _get_preheat_filament_targets(self, db: AsyncSession) -> dict[str, int]:
|
||||
"""Parse the user-configured filament→chamber-target map, falling back
|
||||
to DEFAULT_PREHEAT_FILAMENT_TARGETS on missing / malformed JSON. Keys
|
||||
@@ -4483,14 +4499,14 @@ class PrintScheduler:
|
||||
returned dict so the resolution loop can index it unconditionally."""
|
||||
raw = await self._get_setting(db, "preheat_filament_targets")
|
||||
if not raw:
|
||||
return dict(self.DEFAULT_PREHEAT_FILAMENT_TARGETS)
|
||||
return self._bundled_preheat_targets()
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("not an object")
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
logger.warning("preheat_filament_targets unparseable, using defaults: %s", exc)
|
||||
return dict(self.DEFAULT_PREHEAT_FILAMENT_TARGETS)
|
||||
return self._bundled_preheat_targets()
|
||||
# Coerce values to int; drop unparseable rows so a stray string
|
||||
# doesn't crash the loop.
|
||||
out: dict[str, int] = {}
|
||||
@@ -4539,7 +4555,14 @@ class PrintScheduler:
|
||||
normalised = self._normalize_filament_type(tray.get("tray_type") or "")
|
||||
if not normalised:
|
||||
continue
|
||||
target = targets.get(normalised, targets.get("DEFAULT", 0))
|
||||
# A filled or foamed variant wants its base material's chamber
|
||||
# when the map has no row of its own: ASA-GF is ASA and needs
|
||||
# ASA's 45 degrees, not the 0 an unknown type falls to. The
|
||||
# specific type is still tried first, so PETG-CF and PA-CF keep
|
||||
# the hotter rows they are listed with (#2902).
|
||||
target = targets.get(normalised)
|
||||
if target is None:
|
||||
target = targets.get(normalised.split("-")[0], targets.get("DEFAULT", 0))
|
||||
if target > best:
|
||||
best = target
|
||||
return best
|
||||
|
||||
@@ -50,6 +50,28 @@ from backend.app.utils.filament_types import is_material_name
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _preset_filament_type(raw: object) -> str | None:
|
||||
"""Read a slicer preset's ``filament_type`` field.
|
||||
|
||||
Bambu Studio and OrcaSlicer both store it as a one-element array
|
||||
(``["PLA"]``); some hand-written and older profiles store a bare string.
|
||||
``orca_profiles._extract_filament_fields`` accepts both and this has to
|
||||
agree with it, since that is what fills ``LocalPreset.filament_type``.
|
||||
|
||||
The value is still run through ``printer_filament_type`` by the caller.
|
||||
That is a no-op for every type the app knows -- ``TestTheMaterialsBambuddyOffers``
|
||||
pins exactly that -- and a pass-through for a type it does not, so nothing
|
||||
the slicer says is discarded. It only bites on a hand-edited profile whose
|
||||
``filament_type`` is a product line, which is the case this whole module
|
||||
exists to keep out of an AMS slot.
|
||||
"""
|
||||
if isinstance(raw, list):
|
||||
raw = raw[0] if raw else None
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return raw.strip()
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_slicer_filament(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
@@ -57,7 +79,7 @@ async def resolve_slicer_filament(
|
||||
slicer_filament: str | None,
|
||||
slicer_filament_name: str | None,
|
||||
material: str | None,
|
||||
) -> tuple[str, str, str | None]:
|
||||
) -> tuple[str, str, str | None, str | None]:
|
||||
"""Resolve a spool's slicer-preset reference to printer-side ids.
|
||||
|
||||
``slicer_filament``: the spool's stored reference (e.g. ``"GFA01"``,
|
||||
@@ -72,18 +94,30 @@ async def resolve_slicer_filament(
|
||||
``material``: spool material string for the local-preset fallback
|
||||
branch when the LocalPreset's setting JSON doesn't carry a filament_id.
|
||||
|
||||
Returns ``(tray_info_idx, setting_id, sub_brand_override)`` — all empty
|
||||
when nothing resolved. ``sub_brand_override`` is non-None when a more
|
||||
specific brand label is available (cloud detail name or local preset
|
||||
Returns ``(tray_info_idx, setting_id, sub_brand_override, type_override)``
|
||||
— all empty when nothing resolved. ``sub_brand_override`` is non-None when
|
||||
a more specific brand label is available (cloud detail name or local preset
|
||||
name); ``None`` means the caller should use its own default.
|
||||
|
||||
``type_override`` is the preset's own ``filament_type`` when the preset
|
||||
carries one — the slicer's answer to what the material is, rather than one
|
||||
parsed out of the spool's material column. It is what the caller should
|
||||
write into ``tray_type``. ``None`` means no preset said, and the caller
|
||||
falls back to reducing the spool's material (``printer_filament_type``).
|
||||
Raised in the #2902 thread by @doncaruana: a preset has to be chosen from
|
||||
a list the slicer defines, so its type needs no interpreting. It cannot be
|
||||
the only source, though — ``slicer_filament`` is nullable on a spool while
|
||||
``material`` is required, and the spool this issue was reported for had no
|
||||
preset at all.
|
||||
"""
|
||||
sf = (slicer_filament or "").strip()
|
||||
if not sf:
|
||||
return ("", "", None)
|
||||
return ("", "", None, None)
|
||||
|
||||
tray_info_idx = ""
|
||||
setting_id = ""
|
||||
sub_brand_override: str | None = None
|
||||
type_override: str | None = None
|
||||
|
||||
base_sf = sf.split("_")[0] if "_" in sf else sf
|
||||
|
||||
@@ -104,6 +138,15 @@ async def resolve_slicer_filament(
|
||||
if cloud is not None and cloud.is_authenticated:
|
||||
try:
|
||||
detail = await cloud.get_setting_detail(base_sf)
|
||||
# The preset's own type, straight from the slicer's own
|
||||
# profile -- no parsing of a product name (#2902). The
|
||||
# preset JSON is nested under ``setting``; some responses
|
||||
# carry it at the top level instead, the same shape spread
|
||||
# ``preset_resolver`` documents.
|
||||
cloud_setting = detail.get("setting")
|
||||
type_override = _preset_filament_type(
|
||||
(cloud_setting if isinstance(cloud_setting, dict) else detail).get("filament_type")
|
||||
)
|
||||
if detail.get("filament_id"):
|
||||
tray_info_idx = detail["filament_id"]
|
||||
cloud_name = detail.get("name", "")
|
||||
@@ -134,6 +177,10 @@ async def resolve_slicer_filament(
|
||||
lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
|
||||
lp = lp_result.scalar_one_or_none()
|
||||
if lp:
|
||||
# The slicer's own answer, extracted from the profile at import
|
||||
# time by ``orca_profiles``. Preferred over anything parsed out
|
||||
# of the spool's material column (#2902).
|
||||
type_override = _preset_filament_type(lp.filament_type)
|
||||
# Local preset's setting JSON carries the printer-recognized
|
||||
# filament_id (e.g. "P4d64437") — use that directly so the
|
||||
# slicer can resolve the specific preset. Falls through to
|
||||
@@ -219,4 +266,4 @@ async def resolve_slicer_filament(
|
||||
):
|
||||
setting_id = ""
|
||||
|
||||
return (tray_info_idx, setting_id, sub_brand_override)
|
||||
return (tray_info_idx, setting_id, sub_brand_override, type_override)
|
||||
|
||||
@@ -77,27 +77,43 @@ def filament_types_compatible(a: str | None, b: str | None) -> bool:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# What a Bambu printer and the slicers accept in an AMS slot's ``tray_type``.
|
||||
# Grounded in the catalogue this repo already carries: every "Generic X" entry
|
||||
# in ``cloud._BUILTIN_FILAMENT_NAMES`` names a real type, the "Bambu X" entries
|
||||
# add the composites, and the frontend's ``parsePresetName`` list contributes
|
||||
# Grounded in the catalogues this repo already carries: ``filament_fields.json``
|
||||
# is the list Bambuddy itself offers when a preset is created, so every value in
|
||||
# it has to appear here -- ``TestTheMaterialsBambuddyOffers`` fails if one does
|
||||
# not. On top of that, every "Generic X" entry in
|
||||
# ``cloud._BUILTIN_FILAMENT_NAMES`` names a real type, the "Bambu X" entries add
|
||||
# the composites, and the frontend's ``parsePresetName`` list contributes
|
||||
# PEEK / PEI / PC-CF / PC-ABS.
|
||||
#
|
||||
# Product lines are deliberately absent. "PLA Matte", "PETG HF" and "eSUN PLA+"
|
||||
# are things you buy, not types the firmware knows -- they belong in
|
||||
# ``tray_sub_brands``, which is where Bambu itself puts them.
|
||||
# A filled or foamed variant is a type of its own, not a flavour of the base
|
||||
# material: PLA-AERO is a foaming PLA and PLA-GF a glass-filled one, and a slot
|
||||
# that reduces either to "PLA" invites a plain PLA plate onto filament that
|
||||
# will not print it. Four of the ones the dropdown offers were missing when
|
||||
# #2902 first landed and were being reduced exactly that way, ASA-AERO -- which
|
||||
# only the cloud catalogue names (GFB02) -- with them. See the issue thread,
|
||||
# where @doncaruana caught PLA Aero.
|
||||
#
|
||||
# Product lines, by contrast, are deliberately absent. "PLA Matte", "PETG HF"
|
||||
# and "eSUN PLA+" are things you buy, not types the firmware knows -- they
|
||||
# belong in ``tray_sub_brands``, which is where Bambu itself puts them.
|
||||
_PRINTER_TYPES: tuple[str, ...] = (
|
||||
# Order within a length matters for ties: "PLA/PHA" must read as PLA.
|
||||
"PLA-AERO",
|
||||
"ASA-AERO",
|
||||
"PAHT-CF",
|
||||
"PA12-CF",
|
||||
"PETG-CF",
|
||||
"PPS-CF",
|
||||
"PPS-GF",
|
||||
"PPA-CF",
|
||||
"PPA-GF",
|
||||
"PLA-CF",
|
||||
"PLA-GF",
|
||||
"PA6-CF",
|
||||
"PA6-GF",
|
||||
"ABS-GF",
|
||||
"ASA-CF",
|
||||
"ASA-GF",
|
||||
"PET-CF",
|
||||
"PC-ABS",
|
||||
"PA-CF",
|
||||
@@ -190,6 +206,23 @@ def printer_filament_type(material: str | None) -> str:
|
||||
return _TYPE_ALIASES[upper]
|
||||
|
||||
words = [w for w in _WORDS.split(upper) if w]
|
||||
|
||||
# A hyphenated type written with a space is still that type. The table
|
||||
# spells it "PLA-AERO" because that is how the preset dropdown and the
|
||||
# slicers spell it, while a spool says "PLA Aero" and a preset name says
|
||||
# "Bambu PLA Aero" -- and the word rules below would find only the "PLA"
|
||||
# in those and hand back a slot that lies about what is loaded.
|
||||
#
|
||||
# Adjacent words only, and only when the join is a type exactly. The prefix
|
||||
# and suffix rules are deliberately not applied across a space: "Support
|
||||
# for PLA" would otherwise start reading as a type by its tail.
|
||||
for first, second in zip(words, words[1:], strict=False):
|
||||
joined = f"{first}-{second}"
|
||||
if joined in _PRINTER_TYPE_SET:
|
||||
return joined
|
||||
if joined in _TYPE_ALIASES:
|
||||
return _TYPE_ALIASES[joined]
|
||||
|
||||
for candidate in _TYPES_LONGEST_FIRST:
|
||||
if any(_word_names_type(w, candidate) for w in words):
|
||||
return _TYPE_ALIASES.get(candidate, candidate)
|
||||
@@ -197,6 +230,26 @@ def printer_filament_type(material: str | None) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def nozzle_temp_range(material: str | None, tray_type: str | None) -> tuple[int, int]:
|
||||
"""The nozzle range to send with a slot, given a spool's material and the
|
||||
type the slot will carry.
|
||||
|
||||
The spool's own wording leads, as it does for the filament-id lookup, so a
|
||||
material that has its own entry keeps it. The reduced type answers for
|
||||
everything else -- and when the reduced type is a filled or foamed variant,
|
||||
the base material answers for that, because ``MATERIAL_TEMPS`` carries
|
||||
eleven entries and none of them is ASA-GF. Without that last step an
|
||||
ASA-GF spool took the 200/240 catch-all and would not have extruded;
|
||||
"ASA" gives it ASA's 240/270 (#2902).
|
||||
"""
|
||||
base = (tray_type or "").split("-")[0]
|
||||
for key in (material, tray_type, base):
|
||||
temps = MATERIAL_TEMPS.get((key or "").upper().strip())
|
||||
if temps:
|
||||
return temps
|
||||
return (200, 240)
|
||||
|
||||
|
||||
# Both tables are keyed by material, so their keys are exactly the set of names
|
||||
# that are a material rather than a preset the printer can resolve.
|
||||
_MATERIAL_NAMES = frozenset(MATERIAL_TEMPS) | frozenset(GENERIC_FILAMENT_IDS)
|
||||
@@ -229,4 +282,18 @@ def is_material_name(value: str | None) -> bool:
|
||||
return True
|
||||
if _PRESET_ID_SHAPE.match(text):
|
||||
return False
|
||||
return printer_filament_type(text).upper() in _MATERIAL_NAMES
|
||||
reduced = printer_filament_type(text).upper()
|
||||
if reduced in _MATERIAL_NAMES:
|
||||
return True
|
||||
# A filled or foamed variant is its base material by another name, and the
|
||||
# base is what decides. Saying yes means the caller throws this value away
|
||||
# and rescues the slot from its generic-material fallback -- so the answer
|
||||
# has to be no when that fallback has nothing to offer, or the slot goes out
|
||||
# with no filament id at all, which is worse than the junk it replaced.
|
||||
# ``ABS-GF`` reduces to a generic ABS the printer can resolve; ``PPS-CF``
|
||||
# reduces to nothing, so it is left for the caller to send as it stands.
|
||||
#
|
||||
# This is also what keeps a type added to the table above from silently
|
||||
# changing the answer: "PLA-AERO" read as a material name when the table
|
||||
# had no row for it, and it still does (#2902).
|
||||
return reduced.split("-")[0] in _MATERIAL_NAMES
|
||||
|
||||
@@ -545,14 +545,29 @@ class TestTheAssignmentSurvivesTheSlotItJustConfigured:
|
||||
assigned to. Correct in isolation, ruinous together.
|
||||
"""
|
||||
|
||||
async def _push(self, db_session, printer_factory, spool_material, reported_type, fingerprint_type="PETG"):
|
||||
async def _push(
|
||||
self,
|
||||
db_session,
|
||||
printer_factory,
|
||||
spool_material,
|
||||
reported_type,
|
||||
fingerprint_type="PETG",
|
||||
**spool_kwargs,
|
||||
):
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from backend.app.main import on_ams_change
|
||||
from backend.app.models.spool_assignment import SpoolAssignment
|
||||
|
||||
printer = await printer_factory(name="H2D")
|
||||
spool = Spool(material=spool_material, brand="eSUN", rgba="E1E9E9FF", label_weight=1000, weight_used=0)
|
||||
spool = Spool(
|
||||
material=spool_material,
|
||||
brand="eSUN",
|
||||
rgba="E1E9E9FF",
|
||||
label_weight=1000,
|
||||
weight_used=0,
|
||||
**spool_kwargs,
|
||||
)
|
||||
db_session.add(spool)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(spool)
|
||||
@@ -612,6 +627,55 @@ class TestTheAssignmentSurvivesTheSlotItJustConfigured:
|
||||
|
||||
assert surviving is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_nor_is_one_whose_slot_took_its_presets_type_rather_than_its_material(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
|
||||
):
|
||||
"""The preset outranks the material column when the spool has one, so
|
||||
the slot can legitimately carry a type the material never named. The
|
||||
check has to recognise that as its own handiwork or it unlinks the
|
||||
assignment on the very next AMS push."""
|
||||
surviving = await self._push(
|
||||
db_session,
|
||||
printer_factory,
|
||||
"PLA",
|
||||
reported_type="PLA-AERO",
|
||||
slicer_filament_name="Bambu PLA Aero @BBL H2D",
|
||||
)
|
||||
|
||||
assert surviving is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_even_when_the_preset_name_was_never_stored(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
|
||||
):
|
||||
"""slicer_filament_name is optional. An imported local preset carries
|
||||
its type outright, which is the value the assign path actually used."""
|
||||
from backend.app.models.local_preset import LocalPreset
|
||||
|
||||
lp = LocalPreset(
|
||||
name="Bambu PLA Aero @BBL H2D",
|
||||
preset_type="filament",
|
||||
source="orcaslicer",
|
||||
filament_type="PLA-AERO",
|
||||
setting="{}",
|
||||
)
|
||||
db_session.add(lp)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(lp)
|
||||
|
||||
surviving = await self._push(
|
||||
db_session,
|
||||
printer_factory,
|
||||
"PLA",
|
||||
reported_type="PLA-AERO",
|
||||
slicer_filament=str(lp.id),
|
||||
)
|
||||
|
||||
assert surviving is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_genuinely_different_filament_still_unlinks(
|
||||
@@ -623,3 +687,205 @@ class TestTheAssignmentSurvivesTheSlotItJustConfigured:
|
||||
surviving = await self._push(db_session, printer_factory, "PLA+", reported_type="ABS")
|
||||
|
||||
assert surviving is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_and_a_preset_name_does_not_excuse_an_unrelated_slot(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession, printer_factory
|
||||
):
|
||||
"""Widening the check to the preset only accepts the types the assign
|
||||
path could actually have written. Anything else is still a swap."""
|
||||
surviving = await self._push(
|
||||
db_session,
|
||||
printer_factory,
|
||||
"PLA",
|
||||
reported_type="ABS",
|
||||
slicer_filament_name="Bambu PLA Aero @BBL H2D",
|
||||
)
|
||||
|
||||
assert surviving is None
|
||||
|
||||
|
||||
class TestAFilledOrFoamedVariantIsATypeOfItsOwn:
|
||||
"""The first cut of this fix reduced PLA-AERO, PLA-GF, ASA-GF and PPS-GF
|
||||
onto their base material, because the reduction table was assembled from
|
||||
the cloud filament names and the frontend preset parser and never checked
|
||||
against ``filament_fields.json`` -- the list Bambuddy itself offers when a
|
||||
preset is created. @doncaruana caught PLA Aero on the issue.
|
||||
|
||||
That is worse than the bug it replaced. "PLA-AERO" matched nothing before,
|
||||
which was useless but honest; "PLA" matches every plain PLA plate in the
|
||||
queue, so the dispatcher would have sent one to foaming filament.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
async def printer(self, db_session):
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
p = Printer(
|
||||
name="Aero P1S",
|
||||
serial_number="MAT2902AERO",
|
||||
ip_address="192.168.1.81",
|
||||
access_code="12345678",
|
||||
)
|
||||
db_session.add(p)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(p)
|
||||
return p
|
||||
|
||||
async def _assign(self, async_client, db_session, printer, material, tray_id, **spool_kwargs):
|
||||
spool = Spool(
|
||||
material=material,
|
||||
brand="Bambu Lab",
|
||||
rgba="E1E9E9FF",
|
||||
label_weight=1000,
|
||||
weight_used=0,
|
||||
**spool_kwargs,
|
||||
)
|
||||
db_session.add(spool)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(spool)
|
||||
|
||||
client = _mqtt_mock()
|
||||
with patch("backend.app.services.printer_manager.printer_manager") as pm:
|
||||
pm.get_client.return_value = client
|
||||
pm.get_status.return_value = _status()
|
||||
response = await async_client.post(
|
||||
"/api/v1/inventory/assignments",
|
||||
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": tray_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return client.ams_set_filament_setting.call_args.kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize(
|
||||
("material", "tray_id"),
|
||||
[("PLA-AERO", 0), ("PLA-GF", 1), ("ASA-GF", 2), ("PPS-GF", 3)],
|
||||
)
|
||||
async def test_it_reaches_the_slot_intact(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession, printer, material, tray_id
|
||||
):
|
||||
sent = await self._assign(async_client, db_session, printer, material, tray_id)
|
||||
|
||||
assert sent["tray_type"] == material
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_written_with_a_space_it_still_reaches_the_slot_intact(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession, printer
|
||||
):
|
||||
"""The table hyphenates because the slicers do; a spool says "PLA Aero"
|
||||
and so does every Bambu preset name."""
|
||||
sent = await self._assign(async_client, db_session, printer, "PLA Aero", 0)
|
||||
|
||||
assert sent["tray_type"] == "PLA-AERO"
|
||||
|
||||
|
||||
class TestThePresetOutranksTheMaterialColumn:
|
||||
"""#2902 again, from @doncaruana: a preset has to be picked from a list the
|
||||
slicer defines, so it already knows its own type and nothing has to be read
|
||||
out of a product name. When a spool points at one, that answer wins.
|
||||
|
||||
It cannot be the only answer. ``material`` is required on a spool and
|
||||
``slicer_filament`` is not -- the spool this issue was reported for had no
|
||||
preset at all -- so the reduction stays as the fallback.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
async def printer(self, db_session):
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
p = Printer(
|
||||
name="Preset P1S",
|
||||
serial_number="MAT2902PRE",
|
||||
ip_address="192.168.1.82",
|
||||
access_code="12345678",
|
||||
)
|
||||
db_session.add(p)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(p)
|
||||
return p
|
||||
|
||||
async def _preset(self, db_session, name, filament_type):
|
||||
from backend.app.models.local_preset import LocalPreset
|
||||
|
||||
lp = LocalPreset(
|
||||
name=name,
|
||||
preset_type="filament",
|
||||
source="orcaslicer",
|
||||
filament_type=filament_type,
|
||||
setting="{}",
|
||||
)
|
||||
db_session.add(lp)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(lp)
|
||||
return lp
|
||||
|
||||
async def _assign(self, async_client, db_session, printer, material, preset, tray_id):
|
||||
spool = Spool(
|
||||
material=material,
|
||||
brand="Bambu Lab",
|
||||
rgba="E1E9E9FF",
|
||||
label_weight=1000,
|
||||
weight_used=0,
|
||||
slicer_filament=str(preset.id) if preset else None,
|
||||
)
|
||||
db_session.add(spool)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(spool)
|
||||
|
||||
client = _mqtt_mock()
|
||||
with patch("backend.app.services.printer_manager.printer_manager") as pm:
|
||||
pm.get_client.return_value = client
|
||||
pm.get_status.return_value = _status()
|
||||
response = await async_client.post(
|
||||
"/api/v1/inventory/assignments",
|
||||
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": tray_id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return client.ams_set_filament_setting.call_args.kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_the_slot_gets_the_presets_type_not_one_read_from_the_material(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession, printer
|
||||
):
|
||||
"""The material column says "PLA", which the reduction would happily
|
||||
accept. The preset says the spool is foaming PLA, and it is right."""
|
||||
preset = await self._preset(db_session, "Bambu PLA Aero @BBL P1S", "PLA-AERO")
|
||||
sent = await self._assign(async_client, db_session, printer, "PLA", preset, 0)
|
||||
|
||||
assert sent["tray_type"] == "PLA-AERO"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_preset_that_names_no_type_leaves_the_reduction_in_charge(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession, printer
|
||||
):
|
||||
preset = await self._preset(db_session, "eSUN PLA+ @BBL P1S", None)
|
||||
sent = await self._assign(async_client, db_session, printer, "PLA+", preset, 1)
|
||||
|
||||
assert sent["tray_type"] == "PLA"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_and_a_spool_with_no_preset_at_all_still_gets_one(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession, printer
|
||||
):
|
||||
sent = await self._assign(async_client, db_session, printer, "PLA+", None, 2)
|
||||
|
||||
assert sent["tray_type"] == "PLA"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_hand_edited_preset_naming_a_product_line_is_still_reduced(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession, printer
|
||||
):
|
||||
"""Preferring the preset does not mean trusting it blindly. A profile
|
||||
whose filament_type is a product line puts that product line in the
|
||||
slot, which is the exact failure this issue is about."""
|
||||
preset = await self._preset(db_session, "My PLA+ @BBL P1S", "PLA+")
|
||||
sent = await self._assign(async_client, db_session, printer, "PLA", preset, 3)
|
||||
|
||||
assert sent["tray_type"] == "PLA"
|
||||
|
||||
@@ -35,7 +35,7 @@ async def test_pfus_cloud_unavailable_preserves_setting_id():
|
||||
"backend.app.api.routes.cloud.build_authenticated_cloud",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament(
|
||||
tray_info_idx, setting_id, sub_brand, _type_override = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=None,
|
||||
slicer_filament="PFUS990b6e19965353",
|
||||
@@ -56,7 +56,7 @@ async def test_pfcn_cloud_unavailable_preserves_setting_id():
|
||||
"backend.app.api.routes.cloud.build_authenticated_cloud",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament(
|
||||
tray_info_idx, setting_id, sub_brand, _type_override = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=None,
|
||||
slicer_filament="PFCN1234567890",
|
||||
@@ -82,7 +82,7 @@ async def test_pfus_cloud_resolves_filament_id_regression_guard():
|
||||
"backend.app.api.routes.cloud.build_authenticated_cloud",
|
||||
AsyncMock(return_value=cloud_mock),
|
||||
):
|
||||
tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament(
|
||||
tray_info_idx, setting_id, sub_brand, _type_override = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=MagicMock(),
|
||||
slicer_filament="PFUS990b6e19965353",
|
||||
@@ -106,7 +106,7 @@ async def test_gfs_cloud_unavailable_resolves_via_normalize():
|
||||
"backend.app.api.routes.cloud.build_authenticated_cloud",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament(
|
||||
tray_info_idx, setting_id, sub_brand, _type_override = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=None,
|
||||
slicer_filament="GFSG02",
|
||||
@@ -130,7 +130,7 @@ async def test_literal_material_name_clears_both():
|
||||
"backend.app.api.routes.cloud.build_authenticated_cloud",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
tray_info_idx, setting_id, sub_brand = await resolve_slicer_filament(
|
||||
tray_info_idx, setting_id, sub_brand, _type_override = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=None,
|
||||
slicer_filament="PETG",
|
||||
@@ -140,3 +140,123 @@ async def test_literal_material_name_clears_both():
|
||||
assert tray_info_idx == ""
|
||||
assert setting_id == ""
|
||||
assert sub_brand is None
|
||||
|
||||
|
||||
class TestThePresetsOwnType:
|
||||
"""#2902: a preset is chosen from a list the slicer defines, so its
|
||||
``filament_type`` is the slicer's own answer to what the material is --
|
||||
no reading of a product name required. Raised by @doncaruana on the issue
|
||||
after the first fix reduced "PLA Aero" to "PLA".
|
||||
|
||||
The resolver hands that answer back as the fourth element; the two assign
|
||||
routes write it into ``tray_type`` in preference to reducing the spool's
|
||||
material column. ``None`` means no preset said, and the reduction stands.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_local_presets_type_is_returned(self):
|
||||
db = MagicMock()
|
||||
lp = MagicMock()
|
||||
lp.filament_type = "PLA-AERO"
|
||||
lp.setting = None
|
||||
lp.name = "Bambu PLA Aero @BBL X1C"
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none = MagicMock(return_value=lp)
|
||||
db.execute = AsyncMock(return_value=result)
|
||||
|
||||
_idx, _sid, _brand, type_override = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=None,
|
||||
slicer_filament="38",
|
||||
slicer_filament_name=None,
|
||||
material="PLA",
|
||||
)
|
||||
assert type_override == "PLA-AERO"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_cloud_presets_type_is_read_out_of_its_profile(self):
|
||||
"""Both slicers store it as a one-element array, and the preset JSON
|
||||
sits under ``setting`` in the cloud envelope."""
|
||||
db = MagicMock()
|
||||
cloud = MagicMock()
|
||||
cloud.is_authenticated = True
|
||||
cloud.get_setting_detail = AsyncMock(
|
||||
return_value={
|
||||
"filament_id": "GFA11",
|
||||
"name": "Bambu PLA Aero @BBL X1C",
|
||||
"setting": {"filament_type": ["PLA-AERO"]},
|
||||
}
|
||||
)
|
||||
cloud.close = AsyncMock()
|
||||
with patch(
|
||||
"backend.app.api.routes.cloud.build_authenticated_cloud",
|
||||
AsyncMock(return_value=cloud),
|
||||
):
|
||||
idx, _sid, _brand, type_override = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=None,
|
||||
slicer_filament="GFSA11",
|
||||
slicer_filament_name=None,
|
||||
material="PLA",
|
||||
)
|
||||
assert idx == "GFA11"
|
||||
assert type_override == "PLA-AERO"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_bare_string_filament_type_is_accepted_too(self):
|
||||
"""Hand-written and older profiles store it unwrapped. ``orca_profiles``
|
||||
accepts both forms, so this has to as well."""
|
||||
db = MagicMock()
|
||||
cloud = MagicMock()
|
||||
cloud.is_authenticated = True
|
||||
cloud.get_setting_detail = AsyncMock(
|
||||
return_value={"filament_id": "GFG02", "setting": {"filament_type": "PETG"}}
|
||||
)
|
||||
cloud.close = AsyncMock()
|
||||
with patch(
|
||||
"backend.app.api.routes.cloud.build_authenticated_cloud",
|
||||
AsyncMock(return_value=cloud),
|
||||
):
|
||||
_idx, _sid, _brand, type_override = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=None,
|
||||
slicer_filament="GFSG02",
|
||||
slicer_filament_name=None,
|
||||
material="PETG",
|
||||
)
|
||||
assert type_override == "PETG"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_preset_means_no_answer(self):
|
||||
"""A spool with no slicer_filament -- the case this issue was reported
|
||||
for. ``material`` is required on a spool and ``slicer_filament`` is
|
||||
not, so the reduction has to stay as the fallback."""
|
||||
db = MagicMock()
|
||||
_idx, _sid, _brand, type_override = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=None,
|
||||
slicer_filament=None,
|
||||
slicer_filament_name=None,
|
||||
material="PLA+",
|
||||
)
|
||||
assert type_override is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_preset_that_does_not_say_gets_no_opinion(self):
|
||||
db = MagicMock()
|
||||
cloud = MagicMock()
|
||||
cloud.is_authenticated = True
|
||||
cloud.get_setting_detail = AsyncMock(return_value={"filament_id": "GFG02", "setting": {}})
|
||||
cloud.close = AsyncMock()
|
||||
with patch(
|
||||
"backend.app.api.routes.cloud.build_authenticated_cloud",
|
||||
AsyncMock(return_value=cloud),
|
||||
):
|
||||
_idx, _sid, _brand, type_override = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=None,
|
||||
slicer_filament="GFSG02",
|
||||
slicer_filament_name=None,
|
||||
material="PETG",
|
||||
)
|
||||
assert type_override is None
|
||||
|
||||
@@ -500,20 +500,41 @@ async def test_normalize_filament_type_strips_at_space():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_preheat_filament_targets_defaults_when_missing(scheduler):
|
||||
"""Empty / null setting → bundled defaults are used. _get_preheat_filament_targets
|
||||
upper-cases the keys, so the bundled `default` becomes `DEFAULT` on the
|
||||
returned dict — keep both spellings synced."""
|
||||
@pytest.mark.parametrize("stored", [None, "", "not json at all", "[1, 2, 3]"])
|
||||
async def test_get_preheat_filament_targets_defaults_when_missing(scheduler, stored):
|
||||
"""Empty / null / malformed setting → bundled defaults are used.
|
||||
|
||||
Every path out of this function must honour the one contract its docstring
|
||||
states: keys upper-cased, and DEFAULT present so the resolution loop can
|
||||
index it unconditionally. The fallback paths used to hand the bundled
|
||||
constant back as declared, with its lowercase `default`, so an install that
|
||||
had never opened the setting returned a dict the loop could not read its
|
||||
fallback out of. It happened to produce the right number only because that
|
||||
default is 0 -- this test is what stops the constant changing and taking
|
||||
every unconfigured install's chamber preheat down with it.
|
||||
"""
|
||||
db = AsyncMock()
|
||||
with patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)):
|
||||
with patch.object(scheduler, "_get_setting", AsyncMock(return_value=stored)):
|
||||
targets = await scheduler._get_preheat_filament_targets(db)
|
||||
# The bundled defaults dict is kept as-is on the "no setting" path, so
|
||||
# `default` (lowercase) is what callers see for that fallback.
|
||||
assert targets["PLA"] == 0
|
||||
assert targets["ABS"] == 45
|
||||
assert targets["PA-CF"] == 55
|
||||
# Either casing must resolve to the fallback 0.
|
||||
assert targets.get("default", targets.get("DEFAULT")) == 0
|
||||
assert "DEFAULT" in targets, "the resolution loop looks the fallback up by this exact key"
|
||||
assert targets["DEFAULT"] == PrintScheduler.DEFAULT_PREHEAT_FILAMENT_TARGETS["default"]
|
||||
assert all(key == key.upper() for key in targets), targets
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_configured_map_reaches_the_loop_under_the_same_contract(scheduler):
|
||||
"""The editor writes the keys it displays, lowercase `default` included, so
|
||||
the parsed path has always upper-cased. Both paths agree now."""
|
||||
db = AsyncMock()
|
||||
stored = '{"PLA": 0, "abs": 60, "default": 15}'
|
||||
with patch.object(scheduler, "_get_setting", AsyncMock(return_value=stored)):
|
||||
targets = await scheduler._get_preheat_filament_targets(db)
|
||||
assert targets["ABS"] == 60
|
||||
assert targets["DEFAULT"] == 15
|
||||
assert all(key == key.upper() for key in targets), targets
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
@@ -618,3 +639,32 @@ async def test_x1c_no_airduct_flap_never_fires_set_airduct(scheduler, item, arch
|
||||
await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
|
||||
|
||||
client.set_airduct_mode.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_filled_variant_preheats_like_its_base_material(monkeypatch):
|
||||
"""#2902 widened the type an AMS slot carries, so a slot that used to say
|
||||
"ASA" can now say "ASA-GF". The chamber map has no ASA-GF row, and an
|
||||
unknown type preheats to nothing -- so an ASA-GF print would have gone out
|
||||
with a cold chamber. The specific type is still tried first, so the rows
|
||||
that do exist for a variant (PETG-CF, PA-CF) are not traded down."""
|
||||
from backend.app.services.print_scheduler import PrintScheduler
|
||||
|
||||
s = PrintScheduler()
|
||||
targets = PrintScheduler.DEFAULT_PREHEAT_FILAMENT_TARGETS
|
||||
|
||||
def target_for(tray_type: str) -> int:
|
||||
normalised = s._normalize_filament_type(tray_type)
|
||||
value = targets.get(normalised)
|
||||
if value is None:
|
||||
value = targets.get(normalised.split("-")[0], targets.get("DEFAULT", 0))
|
||||
return value
|
||||
|
||||
assert target_for("ASA-GF") == targets["ASA"]
|
||||
assert target_for("ASA-AERO") == targets["ASA"]
|
||||
assert target_for("ABS-GF") == targets["ABS"]
|
||||
# Variants listed in their own right keep their own row.
|
||||
assert target_for("PETG-CF") == 40
|
||||
assert target_for("PA-CF") == 55
|
||||
# And a plain type is untouched.
|
||||
assert target_for("PLA") == 0
|
||||
|
||||
@@ -11,6 +11,9 @@ is the vendor's product line, and roughly forty of its values are not filament
|
||||
types: HTPLA, PolyTerra PLA, PLA Matte, ASA Extrafill, Flexfill TPU 98A.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG
|
||||
@@ -18,10 +21,22 @@ from backend.app.utils.filament_ids import filament_id_to_setting_id
|
||||
from backend.app.utils.filament_types import (
|
||||
_PRINTER_TYPES,
|
||||
is_material_name,
|
||||
nozzle_temp_range,
|
||||
printer_filament_type,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def offered_types() -> set[str]:
|
||||
"""The ``filament_type`` options of the Profiles editor -- the same file
|
||||
``test_filament_fields_options`` pins for #1686."""
|
||||
path = Path(__file__).resolve().parents[3] / "app" / "data" / "filament_fields.json"
|
||||
with path.open() as f:
|
||||
data = json.load(f)
|
||||
field = next(f for f in data["fields"] if f["key"] == "filament_type")
|
||||
return {opt["value"] for opt in field["options"]}
|
||||
|
||||
|
||||
class TestTheReportedCase:
|
||||
def test_pla_plus_is_pla(self):
|
||||
assert printer_filament_type("PLA+") == "PLA"
|
||||
@@ -139,6 +154,59 @@ class TestTheShippedCatalogue:
|
||||
assert leftover == {"CPE HG100", "FiberSilk Metallic", "XT", "NylonX", "NylonG"}
|
||||
|
||||
|
||||
class TestTheMaterialsBambuddyOffers:
|
||||
"""``filament_fields.json`` is the material list the Profiles editor puts in
|
||||
a dropdown, so every value in it is a type a user can legitimately end up
|
||||
with on a spool -- and reducing one of those to something shorter is not a
|
||||
repair, it is a slot lying about what is loaded.
|
||||
|
||||
Four were being reduced that way when #2902 first landed: PLA-AERO, PLA-GF,
|
||||
ASA-GF and PPS-GF all collapsed onto their base material, so a plain PLA
|
||||
plate would have dispatched onto foaming or glass-filled filament -- a match
|
||||
that was impossible before the reduction existed. @doncaruana caught PLA
|
||||
Aero in the issue thread.
|
||||
|
||||
The reduction table was assembled from the cloud filament names and the
|
||||
frontend preset parser and never checked against this file, which is exactly
|
||||
the drift this test exists to stop.
|
||||
"""
|
||||
|
||||
def test_every_type_the_dropdown_offers_reduces_to_itself(self, offered_types):
|
||||
for material in sorted(offered_types):
|
||||
assert printer_filament_type(material) == material, (
|
||||
f"{material!r} is offered as a filament type but reduces to {printer_filament_type(material)!r}"
|
||||
)
|
||||
|
||||
def test_and_is_a_type_the_reduction_knows(self, offered_types):
|
||||
"""The check above passes vacuously for a name the table cannot place at
|
||||
all, since an unplaceable name is returned verbatim. This one does not."""
|
||||
assert offered_types <= set(_PRINTER_TYPES)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("written", "expected"),
|
||||
[
|
||||
("PLA Aero", "PLA-AERO"),
|
||||
("Bambu PLA Aero", "PLA-AERO"),
|
||||
("ASA Aero", "ASA-AERO"),
|
||||
("PLA GF", "PLA-GF"),
|
||||
("PA6 GF", "PA6-GF"),
|
||||
],
|
||||
)
|
||||
def test_a_hyphenated_type_survives_being_written_with_a_space(self, written, expected):
|
||||
"""Bambu's own preset names are spaced ("Bambu PLA Aero") and so is what
|
||||
a user types, while the table and the slicers hyphenate. Reading only
|
||||
the "PLA" out of those is how the four above were being lost."""
|
||||
assert printer_filament_type(written) == expected
|
||||
|
||||
def test_but_only_when_the_join_is_a_type_exactly(self):
|
||||
"""The prefix and suffix rules stay inside a single word. Applying them
|
||||
across a space would make "Support for PLA" a type by its tail, and
|
||||
every "<brand> PLA Basic" preset name a type by its head."""
|
||||
assert printer_filament_type("PLA Basic") == "PLA"
|
||||
assert printer_filament_type("Bambu PLA Basic") == "PLA"
|
||||
assert printer_filament_type("PLA Silk") == "PLA"
|
||||
|
||||
|
||||
class TestIsMaterialName:
|
||||
"""The question the slicer-filament resolver and the slot-reuse check both
|
||||
ask of a candidate filament id, and have to answer the same way."""
|
||||
@@ -183,9 +251,60 @@ class TestIsMaterialName:
|
||||
assert not is_material_name(fid), fid
|
||||
assert not is_material_name(filament_id_to_setting_id(fid)), fid
|
||||
|
||||
@pytest.mark.parametrize("unknown", ["PCTG", "PPS-CF", "CPE HG100", "XT", "", None])
|
||||
@pytest.mark.parametrize("unknown", ["PCTG", "PPS-CF", "PPS-GF", "PEEK", "PA6-CF", "CPE HG100", "XT", "", None])
|
||||
def test_anything_it_cannot_place_is_left_for_the_caller_to_use(self, unknown):
|
||||
"""False means "keep it". A type the tables do not carry is not proof
|
||||
the value is junk, and discarding it would empty the slot's filament id
|
||||
for no reason."""
|
||||
assert is_material_name(unknown) is False
|
||||
|
||||
@pytest.mark.parametrize("variant", ["PLA-AERO", "PLA-GF", "ASA-GF", "ASA-AERO", "ABS-GF", "ASA-CF"])
|
||||
def test_but_a_variant_the_caller_can_rescue_is_one(self, variant):
|
||||
"""The base decides, because the base is what the caller's fallback
|
||||
looks up. Adding PLA-AERO to the type table must not quietly turn this
|
||||
answer over: before the table had a row for it the value reduced to
|
||||
"PLA" and read as a material name, and it is no less one now -- a slot
|
||||
carrying it as a filament id still has to be replaced, not reused."""
|
||||
assert is_material_name(variant) is True
|
||||
|
||||
|
||||
class TestTheNozzleRangeThatGoesWithIt:
|
||||
"""``MATERIAL_TEMPS`` carries eleven entries, and the reduction can now
|
||||
return forty-one types. Without a fall back to the base material, widening
|
||||
the table sent an ASA-GF spool out at the 200/240 catch-all -- too cold to
|
||||
extrude ASA -- where before it correctly got ASA's own range.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("material", "expected"),
|
||||
[
|
||||
# The ones the widened table would otherwise have cooled.
|
||||
("ASA-GF", (240, 270)),
|
||||
("ASA Aero", (240, 270)),
|
||||
("ASA-CF", (240, 270)),
|
||||
("ABS-GF", (240, 270)),
|
||||
# Unchanged by the widening, and must stay that way.
|
||||
("PLA+", (190, 230)),
|
||||
("PLA-AERO", (190, 230)),
|
||||
("PLA-GF", (190, 230)),
|
||||
("PLA", (190, 230)),
|
||||
("PETG", (220, 260)),
|
||||
],
|
||||
)
|
||||
def test_a_variant_falls_back_to_its_base_material(self, material, expected):
|
||||
assert nozzle_temp_range(material, printer_filament_type(material)) == expected
|
||||
|
||||
def test_but_a_variant_with_its_own_row_keeps_it(self):
|
||||
"""The base fallback is the last step, not the first: PLA-CF prints
|
||||
hotter than PLA and PETG-CF hotter than PETG."""
|
||||
assert nozzle_temp_range("PLA-CF", "PLA-CF") == (210, 240)
|
||||
assert nozzle_temp_range("PETG CF", printer_filament_type("PETG CF")) == (240, 270)
|
||||
|
||||
def test_the_spools_own_wording_still_leads(self):
|
||||
"""Same order as the filament-id lookup: a material that resolves on
|
||||
its own name keeps that answer."""
|
||||
assert nozzle_temp_range("PA-CF", "PA-CF") == (270, 300)
|
||||
|
||||
def test_and_something_unplaceable_still_gets_the_catch_all(self):
|
||||
assert nozzle_temp_range("CPE HG100", "CPE HG100") == (200, 240)
|
||||
assert nozzle_temp_range(None, "") == (200, 240)
|
||||
|
||||
@@ -83,6 +83,11 @@ export function normalizePreheatFilamentType(trayType: string): string {
|
||||
// Pick the max chamber target across a list of loaded tray types, falling
|
||||
// back to `default` when a type isn't in the map. Returns 0 when nothing
|
||||
// is loaded, which short-circuits the chamber phase at dispatch.
|
||||
//
|
||||
// A filled or foamed variant takes its base material's row when it has none of
|
||||
// its own, matching `_derive_chamber_target`: ASA-GF is ASA and wants ASA's 45
|
||||
// degrees, not the 0 an unmapped type falls to. The full type is still tried
|
||||
// first, so PETG-CF and PA-CF keep their own hotter rows (#2902).
|
||||
export function deriveChamberTargetForTrays(
|
||||
trayTypes: readonly string[],
|
||||
map: Record<string, number>,
|
||||
@@ -91,7 +96,7 @@ export function deriveChamberTargetForTrays(
|
||||
for (const raw of trayTypes) {
|
||||
const normalized = normalizePreheatFilamentType(raw);
|
||||
if (!normalized) continue;
|
||||
const target = map[normalized] ?? map.default ?? 0;
|
||||
const target = map[normalized] ?? map[normalized.split('-')[0]] ?? map.default ?? 0;
|
||||
if (target > best) best = target;
|
||||
}
|
||||
return best;
|
||||
|
||||
Reference in New Issue
Block a user