mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
Configure Orca Cloud filaments with their own filament ID (#3216)
OrcaSlicer's Sync filaments finds a slot's preset by the slot's filament
ID alone. The Configure dialog looked up an Orca profile's ID in the
browser and quietly sent the generic for the material when that came back
empty, so Orca custom filaments reached the slicer as Generic and the log
showed nothing. configure now resolves the ID on the server from
orca_profile_id, follows inherits to the parent profile or the Bambu
filament it was copied from, logs the outcome and reports a fallback,
which the dialog shows as a warning.
Slot presets also store the filament ID they were written with, so the
slot card and the Configure dialog stop showing a preset once the slot is
changed from OrcaSlicer's Device tab or the printer.
Re-configuring a slot for another filament no longer carries over the old
filament's active K-profile, which switched the slot back to the old
filament.
This commit is contained in:
+2
-1
@@ -29,6 +29,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Leftover Web Push code and the unused `pywebpush` dependency are gone (#3171)** — Browser push notifications were removed earlier, but the service worker still carried their push handlers and `pywebpush` was still installed, pulling in `py-vapid` and `http_ece`. Both are removed. `aiohttp` stays, now listed as the direct dependency it is for external cameras, with the same version floor.
|
||||
|
||||
### Fixed
|
||||
- **Orca Cloud filaments assigned to an AMS slot showed up in OrcaSlicer as Generic (#3216, reported by @mrnoisytiger)** — OrcaSlicer's **Sync filaments** finds a slot's preset by the filament ID stored in the slot and nothing else. OrcaSlicer's own Device tab writes the profile's filament ID there (`Pfc74047` in the report). For the same profile, Bambuddy wrote the generic ID for the material (`GFL99`), so Sync filaments warned that the filament may not be compatible and used Generic PLA. Bambuddy looked the ID up in the browser, and when that came back empty it used the generic ID without saying so. Nothing in the log showed why. The lookup now happens on the server when the slot is configured, and its result is logged. A profile that only stores its changes against another one now takes the filament ID from that parent: either another of your Orca Cloud profiles, or the Bambu filament it was copied from (for example `GFA00` for Bambu PLA Basic). When no ID can be found, the slot still gets the generic ID, not the filament that was in the slot before, and the dialog shows a warning saying the slicer will see Generic. Also fixed: after a slot was changed from OrcaSlicer's Device tab or the printer's screen, the slot card and the Configure dialog kept showing the preset Bambuddy had last set. Bambuddy now saves the filament ID along with the slot's preset and stops showing that preset once the printer reports a different filament. Slots configured before this update are only checked this way for Bambu's own filaments. Also fixed: re-configuring a slot for a different filament could leave the slot on its old filament. The Configure dialog kept offering the slot's active K-profile, which belongs to the old filament, and selected it automatically. Bambuddy then switched the slot to that K-profile's filament, so OrcaSlicer kept showing the old one until the slot was reset first. The slot's active K-profile is now only offered while the dialog is still on the slot's current filament.
|
||||
- **Turning off "Check printer firmware" stops every firmware lookup** — With the setting off, opening the Printers page still asked bambulab.com once per printer: the page decided before its settings had loaded, and the server answered the request without looking at the setting. The page now waits for the setting, and the server enforces it as well, for both the per-printer and the all-printers check. The lookups could also take a long time on a fresh start (30-second timeouts with fallbacks); on a connection without HTTP/2, which includes a plain `http://` address on the local network, they could hold the browser's few connections and delay the rest of the page.
|
||||
- **The stream overlay's camera recovers from a frozen picture on its own (#3205, reported and contributed by @adamspicedev in #3213)** — When a printer stopped sending frames, Bambuddy ended the camera stream, but the overlay kept showing the last frame and only reconnected if the browser reported an image error, which it doesn't in that case. An unattended overlay in OBS or Meld Studio then stayed frozen until its browser source was refreshed, while the printers page, which checks the camera status, reconnected by itself. The overlay now renews its camera connection every 60 seconds, and still retries 3 seconds after an image error. A renewal joins the camera stream Bambuddy already has open for that printer and shows the latest frame at once, so a working picture doesn't flicker. The overlay URL, token, FPS and rotation are kept. With `camera=false` the overlay makes no camera requests.
|
||||
- **Stream overlay and Cam Wall links keep working after a reload (#3204, reported and contributed by @adamspicedev in #3212)** — Opening an `/overlay/…?token=…` or `/camwall?token=…` link treated the token as a login: Bambuddy removed it from the address bar and tried to sign in with it, which a streaming token can't do. The page worked the first time, but a reload (or an OBS or Meld browser source restarting) had no token left and stayed on **Loading…**. In a browser that was already signed in, the failed attempt also signed the user out. The token now stays in the link and is used only by the overlay or Cam Wall, and an existing login is left alone. SpoolBuddy kiosk links still sign in with their API key as before.
|
||||
@@ -121,7 +122,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **The Finance sidebar entry was hidden from every non-admin, whatever their permissions (#3023, reported by @lonix)** — `cost_centers:read_own` exists so a user can see their own wallet, balance and cost-centre spend, and the page itself always worked if you typed the URL. The sidebar hid it anyway. It decides whether to offer Finance by reading `billing_enabled` from `GET /settings`, which requires `settings:read`; a non-admin gets 403 there, so the value arrived undefined and the entry was suppressed for exactly the people the permission was written for. The shell read three more fields from that same 403, and one of them failed the other way: the Notifications gate tests for `false`, which an undefined value never is, so an administrator who switched user notifications **off** still left the entry showing to the non-admins it governs — a fault no administrator could reproduce, because administrators can read `/settings`. The sponsor prompt also showed EUR regardless of the configured currency, and the update check ran on installs where it had been turned off. `settings:read` cannot be the price of any of this: it also grants sight of the SMTP, LDAP and MQTT credentials. These four now come from a new `GET /settings/ui-flags`, which asks only that the caller be signed in. It is deliberately not more fields on the existing public `/settings/ui-preferences`: that endpoint is served to anyone at all because its contents are defaults shipped with the app, while these describe how your particular install is configured, so they sit behind authentication instead.
|
||||
- **A queued print showed "ASAP" in the queue even when Queue was the option chosen (#3018, reported by @kilrah; also #2557, reported by @ddavidebor)** — the Print dialog offers ASAP, Queue and Schedule, but ASAP and Queue differ only in where the item lands in the list, and neither is recorded on the item itself. The queue's time column had nothing to read but the scheduled time, so it labelled every unscheduled item "ASAP" — the name of the one mode the user may well not have picked. Someone who chose Queue, watched the row appear as ASAP and then watched it start, reasonably concluded Bambuddy had overridden them. That column answers when an item runs, so it now says that: **When a printer is free**, in all fourteen languages. The dispatch itself was correct and is unchanged — a print scheduled for later does not reserve the printer until then, so an unscheduled item behind it uses the printer in the meantime rather than leaving it idle until the scheduled time. The queue's own per-printer log line was no help in telling those apart either: it called a printer "not available" whether the queue could not use it or had just picked it for this pass, and printed printer state read at the moment of logging rather than the state the decision was made on — so a support bundle could show "printer 1 not available — state=IDLE" immediately above that printer receiving a job. Each printer now reports which of the two it was, and why.
|
||||
- **An archive left empty by a printer refusing FTPS blamed the slicer (#2780, reported by @AntonPalmqvist)** — when a printer's file service answers port 990 with something that is not TLS, Bambuddy pauses transfers to it and the print in flight is archived with its name and timing only. That cause has been recorded on the archive since #2957, but the Archives banner was never taught about it: it ranked the three causes it knew, found none of them, and fell back to the original wording — the slicer did not leave the file on the card, go and switch on "Store sent files on external storage", here is a link to installation step 4. Every part of that is wrong for a refused handshake. The slicer did write the file, the reporter could see it on the stick from his own computer, the setting was already on, and there is nothing on his side to change. Worse, the banner dismisses one-shot into browser storage, so being shown the wrong explanation once meant never being shown the right one. It now has its own wording, in all fourteen languages, saying that the printer refused the connection, that this is not a slicer setting and not something the operator did, that Bambuddy comes back for the file after the five-minute pause so a brief episode fills itself in, and that a card still empty means the refusal outlasted the retry. It links to the troubleshooting entry for the handshake failure rather than to the installation guide. This cause now outranks the other three rather than falling to the bottom: the other three describe an install working as configured and each ends in something the operator can change, while this one reports a fault we cannot yet explain — and given the one-shot dismissal, ranking it below anything else would have hidden it permanently. It cannot mask a permanent cause in return, because the retry clears the marker when it succeeds, so a card still carrying this one is a printer whose file service is still refusing. The wiki page these reports are pointed at has also been corrected: it told people to power-cycle the printer, which the reporter who prompted that advice did on both of his printers to no effect, and which the code stopped saying for that reason. It now states what has been measured, says plainly that the trigger is unknown, and names the one log line worth collecting.
|
||||
- **Custom filament profiles arrived in the slicer as Generic, or as the Bambu profile they were built on (#3003, reported by @marivo)** — the slot's filament id is the one field a custom profile travels in, and it holds eight characters on the printer. Bambuddy was putting an eighteen-character preset identifier in it whenever it could not find a real filament id. The printer stored the first eight and reported success, so the slot pointed at something that resolves nowhere: the slicer showed Generic and the printer's calibration table, keyed by the same field, no longer had a slot to key. Three printers in the support archive show it happening — an A1, a P1S and an H2D — so it was never specific to one model. Bambuddy now sends the slot's existing filament id, or the generic one for the material, both of which fit. Orca Cloud profiles were additionally never looked up at all, so theirs went in as a thirty-six-character identifier and fared worse still; they are now resolved like every other source. Also fixed on the way through: a failed Orca Cloud sign-in left its HTTP connection behind instead of closing it, which went unnoticed while only the settings page could trigger it and would have repeated on every spool assignment once the lookup above started using the same code. A profile that carries no filament id of its own — which is every profile created in OrcaSlicer, whose preset format has no such field — still cannot be told apart from the one it inherits from.
|
||||
- **Custom filament profiles arrived in the slicer as Generic, or as the Bambu profile they were built on (#3003, reported by @marivo)** — the slot's filament id is the one field a custom profile travels in, and it holds eight characters on the printer. Bambuddy was putting an eighteen-character preset identifier in it whenever it could not find a real filament id. The printer stored the first eight and reported success, so the slot pointed at something that resolves nowhere: the slicer showed Generic and the printer's calibration table, keyed by the same field, no longer had a slot to key. Three printers in the support archive show it happening — an A1, a P1S and an H2D — so it was never specific to one model. Bambuddy now sends the slot's existing filament id, or the generic one for the material, both of which fit. Orca Cloud profiles were additionally never looked up at all, so theirs went in as a thirty-six-character identifier and fared worse still; they are now resolved like every other source. Also fixed on the way through: a failed Orca Cloud sign-in left its HTTP connection behind instead of closing it, which went unnoticed while only the settings page could trigger it and would have repeated on every spool assignment once the lookup above started using the same code. A profile that carries no filament id of its own — one that only stores its changes against the profile it inherits from — still cannot be told apart from that parent.
|
||||
- **Hovering a muted control in the light theme made its label vanish (#1909, reported by @AntonPalmqvist)** — the tab strip on the Print Queue page was where it got noticed: point at **Batches** and the word turned white on a near-white background. It was never about that tab. The light theme keeps `text-white` readable by remapping it to the theme's foreground colour, but that remapping only ever matched the plain utility, not the `hover:` variant Tailwind compiles to a different selector — so roughly 400 controls across the app that dim their label at rest and brighten it on hover were brightening it to literal white, whatever the theme. They now follow the theme like everything else. Dark themes are unaffected, their foreground colour already being white, and the handful of buttons that turn a solid accent colour on hover read better for it rather than worse.
|
||||
|
||||
### Security
|
||||
|
||||
@@ -389,6 +389,7 @@ async def apply_spool_to_slot_via_mqtt(
|
||||
tray_sub_brands=tray_sub_brands,
|
||||
tray_type=tray_type,
|
||||
setting_id=setting_id,
|
||||
configured_tray_info_idx=effective_tray_info_idx,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -88,7 +88,7 @@ from backend.app.services.printer_media import (
|
||||
remove_printer_files_zip,
|
||||
start_printer_files_job,
|
||||
)
|
||||
from backend.app.services.slicer_filament_resolver import _ORCA_PROFILE_ID
|
||||
from backend.app.services.slicer_filament_resolver import _ORCA_PROFILE_ID, lookup_orca_filament_id
|
||||
from backend.app.services.slot_nozzle import resolve_slot_nozzle
|
||||
from backend.app.utils.ams_humidity import ams_humidity_percent
|
||||
from backend.app.utils.filament_ids import filament_id_to_setting_id
|
||||
@@ -2556,6 +2556,7 @@ async def get_slot_presets(
|
||||
"tray_id": mapping.tray_id,
|
||||
"preset_id": mapping.preset_id,
|
||||
"preset_name": mapping.preset_name,
|
||||
"tray_info_idx": mapping.tray_info_idx,
|
||||
}
|
||||
for mapping in mappings
|
||||
}
|
||||
@@ -2587,6 +2588,7 @@ async def get_slot_preset(
|
||||
"tray_id": mapping.tray_id,
|
||||
"preset_id": mapping.preset_id,
|
||||
"preset_name": mapping.preset_name,
|
||||
"tray_info_idx": mapping.tray_info_idx,
|
||||
}
|
||||
|
||||
|
||||
@@ -2598,10 +2600,20 @@ async def save_slot_preset(
|
||||
preset_id: str,
|
||||
preset_name: str,
|
||||
preset_source: str = "cloud",
|
||||
tray_info_idx: str | None = None,
|
||||
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Save a preset mapping for a specific slot."""
|
||||
"""Save a preset mapping for a specific slot.
|
||||
|
||||
``tray_info_idx`` is the filament id the slot was configured with (what
|
||||
``configure`` returns). Recording it lets the slot card notice a later
|
||||
re-configuration from elsewhere and stop showing this preset (#3216).
|
||||
Omitted, it is cleared, so a stale id never outlives the preset it came with.
|
||||
"""
|
||||
# Longer than the column is not an id the printer holds: record nothing,
|
||||
# which keeps the row shown, rather than refuse the whole save.
|
||||
tray_info_idx = tray_info_idx if tray_info_idx and len(tray_info_idx) <= 32 else None
|
||||
# Check printer exists
|
||||
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
||||
if not result.scalar_one_or_none():
|
||||
@@ -2622,6 +2634,7 @@ async def save_slot_preset(
|
||||
mapping.preset_id = preset_id
|
||||
mapping.preset_name = preset_name
|
||||
mapping.preset_source = preset_source
|
||||
mapping.tray_info_idx = tray_info_idx
|
||||
else:
|
||||
# Create new
|
||||
mapping = SlotPresetMapping(
|
||||
@@ -2631,6 +2644,7 @@ async def save_slot_preset(
|
||||
preset_id=preset_id,
|
||||
preset_name=preset_name,
|
||||
preset_source=preset_source,
|
||||
tray_info_idx=tray_info_idx,
|
||||
)
|
||||
db.add(mapping)
|
||||
|
||||
@@ -2643,6 +2657,7 @@ async def save_slot_preset(
|
||||
"preset_id": mapping.preset_id,
|
||||
"preset_name": mapping.preset_name,
|
||||
"preset_source": mapping.preset_source,
|
||||
"tray_info_idx": mapping.tray_info_idx,
|
||||
}
|
||||
|
||||
|
||||
@@ -2771,6 +2786,46 @@ async def get_slot_spool_defaults(
|
||||
}
|
||||
|
||||
|
||||
# Generic Bambu filament ids by material, as the Configure dialog has always
|
||||
# picked them for a preset without an id of its own (#3216 moved the Orca case
|
||||
# here). Wider than configure_ams_slot's own table: Silk, High Speed, PCTG, PE
|
||||
# and PP have generics of their own.
|
||||
_ORCA_GENERIC_IDS = {
|
||||
"PLA": "GFL99",
|
||||
"PLA-CF": "GFL98",
|
||||
"PLA SILK": "GFL96",
|
||||
"PLA HIGH SPEED": "GFL95",
|
||||
"PETG": "GFG99",
|
||||
"PETG HF": "GFG96",
|
||||
"PETG-CF": "GFG98",
|
||||
"PCTG": "GFG97",
|
||||
"ABS": "GFB99",
|
||||
"ASA": "GFB98",
|
||||
"PC": "GFC99",
|
||||
"PA": "GFN99",
|
||||
"PA-CF": "GFN98",
|
||||
"NYLON": "GFN99",
|
||||
"TPU": "GFU99",
|
||||
"PVA": "GFS99",
|
||||
"HIPS": "GFS98",
|
||||
"PE": "GFP99",
|
||||
"PP": "GFP97",
|
||||
}
|
||||
|
||||
|
||||
def _orca_generic_filament_id(material: str) -> str:
|
||||
"""The generic for a material, tried as given, without a CF suffix, without
|
||||
a trailing "+", then by its first word -- the dialog's order."""
|
||||
material = (material or "").upper().strip()
|
||||
return (
|
||||
_ORCA_GENERIC_IDS.get(material)
|
||||
or _ORCA_GENERIC_IDS.get(re.sub(r"[-\s]?CF$", "", material))
|
||||
or _ORCA_GENERIC_IDS.get(re.sub(r"\+$", "", material))
|
||||
or _ORCA_GENERIC_IDS.get(re.split(r"[-\s]", material)[0])
|
||||
or ""
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{printer_id}/slots/{ams_id}/{tray_id}/configure")
|
||||
async def configure_ams_slot(
|
||||
printer_id: int,
|
||||
@@ -2788,8 +2843,9 @@ async def configure_ams_slot(
|
||||
kprofile_filament_id: str = Query(""),
|
||||
kprofile_setting_id: str = Query(""),
|
||||
k_value: float = Query(0.0),
|
||||
orca_profile_id: str = Query(""),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
|
||||
):
|
||||
"""Configure an AMS slot with a specific filament setting and K profile.
|
||||
|
||||
@@ -2812,6 +2868,9 @@ async def configure_ams_slot(
|
||||
setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
|
||||
kprofile_filament_id: K profile's filament_id for proper K profile linking
|
||||
k_value: Direct K value to set (0.0 to skip direct K value setting)
|
||||
orca_profile_id: Orca Cloud profile the slot is being set to. With no
|
||||
tray_info_idx, the profile's own filament id is looked up here
|
||||
(#3216); the response says when none was found.
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("[configure_ams_slot] printer_id=%s, ams_id=%s, tray_id=%s", printer_id, ams_id, tray_id)
|
||||
@@ -2877,6 +2936,40 @@ async def configure_ams_slot(
|
||||
setting_id = tray_info_idx
|
||||
tray_info_idx = ""
|
||||
|
||||
# An Orca Cloud profile: look up the filament id it puts in the slot. It is
|
||||
# the one value OrcaSlicer's "Sync filaments" matches a slot by, so a
|
||||
# generic here is what turned every Orca custom filament into "Generic
|
||||
# <material>" in the slicer (#3216). Done here rather than in the browser so
|
||||
# the outcome is in the log, and so a failure can be reported back.
|
||||
orca_fallback_reason = ""
|
||||
if orca_profile_id and not tray_info_idx:
|
||||
orca_lookup = await lookup_orca_filament_id(db, current_user, orca_profile_id)
|
||||
found = orca_lookup.filament_id
|
||||
reason = orca_lookup.reason
|
||||
if found and (
|
||||
found.startswith("PFUS")
|
||||
or found.startswith("PFCN")
|
||||
or _ORCA_PROFILE_ID.fullmatch(found)
|
||||
or is_material_name(found)
|
||||
):
|
||||
# Same refusal as the guard above: never put an id the printer
|
||||
# cannot store in the slot.
|
||||
found, reason = "", "no_filament_id"
|
||||
if found:
|
||||
tray_info_idx = found
|
||||
else:
|
||||
orca_fallback_reason = reason or "no_filament_id"
|
||||
# The generic the Configure dialog picked for an Orca profile before
|
||||
# the lookup moved here -- its table, not the shorter one below, so
|
||||
# PLA Silk, PCTG, PP and PE keep their own generics.
|
||||
tray_info_idx = _orca_generic_filament_id(requested_tray_type)
|
||||
logger.info(
|
||||
"[configure_ams_slot] Orca profile %r → tray_info_idx=%r (%s)",
|
||||
orca_profile_id,
|
||||
tray_info_idx,
|
||||
orca_lookup.source or orca_fallback_reason,
|
||||
)
|
||||
|
||||
# Resolve tray_info_idx for the MQTT command.
|
||||
# Priority:
|
||||
# 1. Use the provided tray_info_idx if set, once the guard above has had
|
||||
@@ -2934,7 +3027,11 @@ async def configure_ams_slot(
|
||||
current_tray_type = cur_tray.get("tray_type", "")
|
||||
|
||||
if (
|
||||
current_tray_info_idx
|
||||
# An Orca profile without an id must not inherit whatever specific
|
||||
# filament the slot held before: that would put the previous spool's
|
||||
# preset in front of the slicer. It gets the generic for its material.
|
||||
not orca_profile_id
|
||||
and current_tray_info_idx
|
||||
and current_tray_info_idx not in _GENERIC_ID_VALUES
|
||||
and current_tray_type
|
||||
and current_tray_type.upper() == tray_type.upper()
|
||||
@@ -2981,11 +3078,13 @@ async def configure_ams_slot(
|
||||
# valid tray_info_idx (GF* official, P* local — not PFUS* cloud-user
|
||||
# which the slicer rejects in tray_info_idx).
|
||||
effective_setting_id = setting_id
|
||||
realigned_to_kprofile = False
|
||||
if (
|
||||
kprofile_filament_id
|
||||
and kprofile_filament_id != effective_tray_info_idx
|
||||
and not kprofile_filament_id.startswith("PFUS")
|
||||
):
|
||||
realigned_to_kprofile = True
|
||||
logger.info(
|
||||
"[configure_ams_slot] realigning slot filament context to kp: tray_info_idx %r → %r, setting_id %r → %r",
|
||||
effective_tray_info_idx,
|
||||
@@ -3243,6 +3342,13 @@ async def configure_ams_slot(
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Configured AMS {ams_id} tray {tray_id} with {tray_sub_brands}",
|
||||
# What the slot was actually given, for the slot preset row (#3216).
|
||||
"tray_info_idx": effective_tray_info_idx,
|
||||
# Set when an Orca profile's own filament id could not be used, so the
|
||||
# slicer will see the generic for the material: "no_filament_id",
|
||||
# "lookup_failed" or "no_permission". Cleared when a K-profile realigned
|
||||
# the slot to its own specific filament after all.
|
||||
"orca_fallback_reason": "" if realigned_to_kprofile else orca_fallback_reason,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5197,6 +5197,11 @@ async def run_migrations(conn):
|
||||
text("UPDATE notification_providers SET attach_photo = :on WHERE attach_photo IS NULL"), {"on": True}
|
||||
)
|
||||
|
||||
# Migration: the filament id a slot preset was written with, so the slot
|
||||
# card can tell when something else re-configured the slot (#3216).
|
||||
# Nullable: existing rows have none and keep being shown as before.
|
||||
await _safe_execute(conn, "ALTER TABLE slot_preset_mappings ADD COLUMN tray_info_idx VARCHAR(32)")
|
||||
|
||||
|
||||
async def _migrate_confirm_prompt_body_template(conn) -> None:
|
||||
"""Replace the one-tap verdict URLs in the outcome prompt's body (#1898).
|
||||
|
||||
@@ -25,6 +25,12 @@ class SlotPresetMapping(Base):
|
||||
preset_id: Mapped[str] = mapped_column(String(100)) # Cloud preset setting_id
|
||||
preset_name: Mapped[str] = mapped_column(String(200)) # Preset name for display
|
||||
preset_source: Mapped[str] = mapped_column(String(20), default="cloud") # cloud or local
|
||||
# The filament id the slot was configured with alongside this preset. When
|
||||
# the printer later reports a different one, something else (the slicer's
|
||||
# Device tab, the printer screen) re-configured the slot and this row no
|
||||
# longer describes it (#3216). None for rows written before it was recorded
|
||||
# and by callers that do not know it.
|
||||
tray_info_idx: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -57,28 +58,80 @@ logger = logging.getLogger(__name__)
|
||||
_ORCA_PROFILE_ID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
||||
|
||||
|
||||
async def _orca_filament_id(
|
||||
@dataclass(frozen=True)
|
||||
class OrcaFilamentLookup:
|
||||
"""Outcome of resolving an Orca Cloud profile to a printer filament id.
|
||||
|
||||
``filament_id`` is empty when nothing usable was found; ``reason`` then says
|
||||
why, so a caller in front of a user can say so instead of quietly sending
|
||||
a generic id (#3216). ``source`` is ``"own"`` for the profile's own id,
|
||||
``"inherited"`` for one taken from a parent.
|
||||
"""
|
||||
|
||||
filament_id: str = ""
|
||||
name: str | None = None
|
||||
filament_type: str | None = None
|
||||
source: str = ""
|
||||
reason: str = ""
|
||||
|
||||
|
||||
# How many ``inherits`` hops to follow before giving up. Orca presets are one
|
||||
# or two deep in practice; the cap only guards against a cycle in hand-edited
|
||||
# profiles.
|
||||
_MAX_INHERITS_DEPTH = 5
|
||||
|
||||
|
||||
def _content_filament_id(content: dict) -> str:
|
||||
raw = content.get("filament_id")
|
||||
if isinstance(raw, list):
|
||||
raw = raw[0] if raw else None
|
||||
return raw.strip() if isinstance(raw, str) else ""
|
||||
|
||||
|
||||
def _catalog_filament_id(preset_name: str) -> str:
|
||||
"""Bambu catalog id for a Bambu system preset name, or ``""``.
|
||||
|
||||
An Orca profile that inherits a system preset ("Bambu PLA Basic @BBL H2D")
|
||||
carries no id of its own. Its parent's id is the one the printer and the
|
||||
slicer both know -- OrcaSlicer since 2.5 translates the Bambu catalog ids
|
||||
at the printer boundary -- so it beats a generic for the material.
|
||||
"""
|
||||
from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
|
||||
|
||||
base = preset_name.split("@")[0].strip().lstrip("#").strip().lower()
|
||||
if not base:
|
||||
return ""
|
||||
for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
|
||||
if fname.lower() == base:
|
||||
return fid
|
||||
return ""
|
||||
|
||||
|
||||
async def lookup_orca_filament_id(
|
||||
db: AsyncSession,
|
||||
current_user: User | None,
|
||||
profile_id: str,
|
||||
) -> tuple[str, str | None, str | None]:
|
||||
"""Look up an Orca Cloud profile's own filament_id.
|
||||
) -> OrcaFilamentLookup:
|
||||
"""Look up the filament id an Orca Cloud profile puts in an AMS slot.
|
||||
|
||||
Returns ``(filament_id, name, filament_type)`` -- all empty/None when the
|
||||
profile cannot be fetched or carries no id of its own, which leaves the
|
||||
caller on its generic fallback.
|
||||
OrcaSlicer resolves a slot to a preset by ``filament_id`` alone, so this is
|
||||
the value that decides whether "Sync filaments" finds the user's profile or
|
||||
falls back to Generic (#3216). Tried in order: the profile's own id; the
|
||||
nearest parent among the user's Orca profiles that has one; the Bambu
|
||||
catalog id of a Bambu system parent.
|
||||
|
||||
Best-effort by construction: this runs inside spool assignment, not a user
|
||||
request, so a missing pairing, a revoked token or a lapsed permission must
|
||||
degrade to the fallback rather than fail the assignment. That is also why
|
||||
``clear_on_auth_failure=False`` -- Orca reports every refresh rejection with
|
||||
one composite reason, so a background caller cannot tell a real revocation
|
||||
from a lost rotation race and must not wipe a working pairing on it. The
|
||||
route path hits the same failure in front of a user and clears there.
|
||||
Best-effort by construction: this runs inside spool assignment and slot
|
||||
configuration, so a missing pairing, a revoked token or a lapsed permission
|
||||
must degrade to the caller's fallback rather than fail the request. That is
|
||||
also why ``clear_on_auth_failure=False`` -- Orca reports every refresh
|
||||
rejection with one composite reason, so this caller cannot tell a real
|
||||
revocation from a lost rotation race and must not wipe a working pairing on
|
||||
it. The Orca Cloud routes hit the same failure in front of a user and clear
|
||||
there.
|
||||
"""
|
||||
if current_user is not None and not current_user.has_permission(Permission.ORCA_CLOUD_AUTH.value):
|
||||
logger.debug("Orca filament lookup skipped for %r: caller lacks orca_cloud:auth", profile_id)
|
||||
return ("", None, None)
|
||||
logger.info("Orca filament lookup skipped for %r: caller lacks orca_cloud:auth", profile_id)
|
||||
return OrcaFilamentLookup(reason="no_permission")
|
||||
|
||||
svc = None
|
||||
try:
|
||||
@@ -86,9 +139,70 @@ async def _orca_filament_id(
|
||||
|
||||
svc = await _build_authenticated_service(db, current_user, clear_on_auth_failure=False)
|
||||
profile = await svc.get_profile(profile_id)
|
||||
content = profile.get("content") if isinstance(profile, dict) else None
|
||||
if not isinstance(content, dict):
|
||||
logger.info("Orca filament lookup for %r: profile has no content", profile_id)
|
||||
return OrcaFilamentLookup(reason="no_filament_id")
|
||||
raw_name = profile.get("name")
|
||||
name = raw_name if isinstance(raw_name, str) and raw_name else None
|
||||
filament_type = _preset_filament_type(content.get("filament_type"))
|
||||
|
||||
own = _content_filament_id(content)
|
||||
if own:
|
||||
logger.info("Orca filament lookup for %r: own filament_id %r", profile_id, own)
|
||||
return OrcaFilamentLookup(own, name, filament_type, source="own")
|
||||
|
||||
parent = content.get("inherits")
|
||||
parent = parent.strip() if isinstance(parent, str) else ""
|
||||
by_name: dict[str, dict] | None = None
|
||||
for _ in range(_MAX_INHERITS_DEPTH):
|
||||
if not parent:
|
||||
break
|
||||
if by_name is None:
|
||||
# One more pull, only for a profile that needs its parent. A
|
||||
# failure here keeps the name and type already read, as the
|
||||
# lookup returned them before it followed parents.
|
||||
by_name = {}
|
||||
try:
|
||||
entries = await svc.list_profiles()
|
||||
except Exception as e:
|
||||
logger.info("Orca filament lookup for %r: could not read its parents: %s", profile_id, e)
|
||||
return OrcaFilamentLookup(name=name, filament_type=filament_type, reason="lookup_failed")
|
||||
for entry in entries:
|
||||
entry_content = entry.get("content") if isinstance(entry, dict) else None
|
||||
if not isinstance(entry_content, dict):
|
||||
continue
|
||||
for key in (entry.get("name"), entry_content.get("name")):
|
||||
if isinstance(key, str) and key:
|
||||
by_name.setdefault(key, entry_content)
|
||||
parent_content = by_name.get(parent)
|
||||
if parent_content is None:
|
||||
catalog = _catalog_filament_id(parent)
|
||||
if catalog:
|
||||
logger.info(
|
||||
"Orca filament lookup for %r: catalog filament_id %r of system parent %r",
|
||||
profile_id,
|
||||
catalog,
|
||||
parent,
|
||||
)
|
||||
return OrcaFilamentLookup(catalog, name, filament_type, source="inherited")
|
||||
break
|
||||
inherited = _content_filament_id(parent_content)
|
||||
if inherited:
|
||||
logger.info(
|
||||
"Orca filament lookup for %r: filament_id %r inherited from %r", profile_id, inherited, parent
|
||||
)
|
||||
return OrcaFilamentLookup(inherited, name, filament_type, source="inherited")
|
||||
next_parent = parent_content.get("inherits")
|
||||
parent = next_parent.strip() if isinstance(next_parent, str) else ""
|
||||
|
||||
logger.info("Orca filament lookup for %r: no filament_id on the profile or its parents", profile_id)
|
||||
return OrcaFilamentLookup(name=name, filament_type=filament_type, reason="no_filament_id")
|
||||
except Exception as e:
|
||||
logger.debug("Orca filament lookup failed for %r: %s", profile_id, e)
|
||||
return ("", None, None)
|
||||
# INFO, not DEBUG: this is the line that explains a slot coming out as
|
||||
# Generic in a support bundle (#3216).
|
||||
logger.info("Orca filament lookup failed for %r: %s", profile_id, e)
|
||||
return OrcaFilamentLookup(reason="lookup_failed")
|
||||
finally:
|
||||
# A raise in `finally` escapes the `except` above, so guard it: closing
|
||||
# an httpx client must never be what fails a spool assignment.
|
||||
@@ -98,17 +212,17 @@ async def _orca_filament_id(
|
||||
except Exception as e: # noqa: BLE001 - close() is best-effort
|
||||
logger.debug("Orca client close failed after lookup of %r: %s", profile_id, e)
|
||||
|
||||
content = profile.get("content") if isinstance(profile, dict) else None
|
||||
if not isinstance(content, dict):
|
||||
return ("", None, None)
|
||||
raw_fid = content.get("filament_id")
|
||||
filament_id = raw_fid.strip() if isinstance(raw_fid, str) else ""
|
||||
name = profile.get("name") if isinstance(profile, dict) else None
|
||||
return (
|
||||
filament_id,
|
||||
name if isinstance(name, str) and name else None,
|
||||
_preset_filament_type(content.get("filament_type")),
|
||||
)
|
||||
|
||||
async def _orca_filament_id(
|
||||
db: AsyncSession,
|
||||
current_user: User | None,
|
||||
profile_id: str,
|
||||
) -> tuple[str, str | None, str | None]:
|
||||
"""``(filament_id, name, filament_type)`` for the spool resolver below --
|
||||
all empty/None when nothing was found, which leaves it on its generic
|
||||
fallback. See ``lookup_orca_filament_id``."""
|
||||
found = await lookup_orca_filament_id(db, current_user, profile_id)
|
||||
return (found.filament_id, found.name, found.filament_type)
|
||||
|
||||
|
||||
def _preset_filament_type(raw: object) -> str | None:
|
||||
|
||||
@@ -36,11 +36,17 @@ async def upsert_slot_preset(
|
||||
preset_id: str,
|
||||
preset_name: str,
|
||||
preset_source: str = "cloud",
|
||||
configured_tray_info_idx: str | None = None,
|
||||
) -> None:
|
||||
"""Primitive upsert. No-op when ``preset_id`` is empty (the column is
|
||||
NOT NULL on the model, and an empty string isn't a useful key to
|
||||
overwrite by). Soft-fails on DB errors so a broken upsert never
|
||||
cascades into the surrounding spool-assign flow.
|
||||
|
||||
``configured_tray_info_idx`` is the filament id the slot was given along
|
||||
with this preset, when the caller knows it; the slot card stops trusting
|
||||
the row once the printer reports a different one (#3216). It is replaced
|
||||
on every write, so a stale id never outlives the preset it came with.
|
||||
"""
|
||||
if not preset_id:
|
||||
return
|
||||
@@ -57,6 +63,7 @@ async def upsert_slot_preset(
|
||||
mapping.preset_id = preset_id
|
||||
mapping.preset_name = preset_name
|
||||
mapping.preset_source = preset_source
|
||||
mapping.tray_info_idx = configured_tray_info_idx or None
|
||||
else:
|
||||
mapping = SlotPresetMapping(
|
||||
printer_id=printer_id,
|
||||
@@ -65,6 +72,7 @@ async def upsert_slot_preset(
|
||||
preset_id=preset_id,
|
||||
preset_name=preset_name,
|
||||
preset_source=preset_source,
|
||||
tray_info_idx=configured_tray_info_idx or None,
|
||||
)
|
||||
db.add(mapping)
|
||||
await db.commit()
|
||||
@@ -89,6 +97,7 @@ async def upsert_slot_preset_for_spool(
|
||||
tray_sub_brands: str = "",
|
||||
tray_type: str = "",
|
||||
setting_id: str = "",
|
||||
configured_tray_info_idx: str | None = None,
|
||||
) -> None:
|
||||
"""Convenience wrapper for internal-mode call sites — derives the
|
||||
(preset_id, preset_name, preset_source) triple from a ``Spool`` ORM object,
|
||||
@@ -120,6 +129,7 @@ async def upsert_slot_preset_for_spool(
|
||||
preset_id=preset_id,
|
||||
preset_name=preset_name,
|
||||
preset_source=preset_source,
|
||||
configured_tray_info_idx=configured_tray_info_idx,
|
||||
)
|
||||
|
||||
|
||||
@@ -155,4 +165,6 @@ async def upsert_slot_preset_for_spoolman_spool(
|
||||
preset_id=preset_id,
|
||||
preset_name=preset_name or "",
|
||||
preset_source="cloud",
|
||||
# The AMS-reported id this row was derived from.
|
||||
configured_tray_info_idx=tray_info_idx,
|
||||
)
|
||||
|
||||
@@ -379,6 +379,9 @@ class TestAssignSpoolPresetMapping:
|
||||
# Must use slicer_filament_name, NOT "PLA Silk" from material+subtype
|
||||
assert presets["1"]["preset_name"] == "Bambu PLA Silk"
|
||||
assert presets["1"]["preset_id"] == "GFSA05"
|
||||
# The filament id the slot was given goes with it, so the slot card can
|
||||
# tell a later re-configuration from elsewhere (#3216).
|
||||
assert presets["1"]["tray_info_idx"] == "GFA05"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@@ -398,6 +401,7 @@ class TestAssignSpoolPresetMapping:
|
||||
preset_id="GFSA01",
|
||||
preset_name="Bambu PLA Matte",
|
||||
preset_source="cloud",
|
||||
tray_info_idx="GFA01",
|
||||
)
|
||||
db_session.add(old_mapping)
|
||||
await db_session.commit()
|
||||
@@ -435,6 +439,8 @@ class TestAssignSpoolPresetMapping:
|
||||
# Old "Bambu PLA Matte" must be overwritten
|
||||
assert presets["2"]["preset_name"] == "Generic PLA Silk"
|
||||
assert presets["2"]["preset_id"] == "GFSL96"
|
||||
# ...and so must the filament id recorded with it (#3216).
|
||||
assert presets["2"]["tray_info_idx"] == "GFL96"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"""An Orca Cloud profile reaches the AMS slot with its own filament id (#3216).
|
||||
|
||||
OrcaSlicer's "Sync filaments" resolves a slot to a preset by ``filament_id``
|
||||
alone. The Configure dialog used to look the id up in the browser and, when
|
||||
that came back empty, quietly sent the generic for the material -- so every
|
||||
Orca custom filament reached the slicer as "Generic <material>", and nothing
|
||||
in the log said why. The lookup now runs in ``configure`` and its outcome is
|
||||
reported back.
|
||||
|
||||
The slot preset row also records the filament id it was written with, so the
|
||||
slot card can tell when OrcaSlicer's Device tab re-configured the slot.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from backend.app.services.slicer_filament_resolver import OrcaFilamentLookup
|
||||
|
||||
ORCA_ID = "34d8f588-860b-5be1-bcbe-c0d46d96324b"
|
||||
|
||||
|
||||
def _client():
|
||||
client = MagicMock()
|
||||
client.ams_set_filament_setting.return_value = True
|
||||
client.extrusion_cali_sel.return_value = True
|
||||
client.request_status_update.return_value = True
|
||||
return client
|
||||
|
||||
|
||||
def _status(tray_info_idx: str = "", tray_type: str = ""):
|
||||
status = MagicMock()
|
||||
status.raw_data = {
|
||||
"ams": {
|
||||
"ams": [
|
||||
{"id": 0, "tray": [{"id": 2, "tray_info_idx": tray_info_idx, "tray_type": tray_type}]},
|
||||
]
|
||||
}
|
||||
}
|
||||
return status
|
||||
|
||||
|
||||
async def _configure(async_client: AsyncClient, printer_id: int, **extra):
|
||||
params = {
|
||||
"tray_info_idx": "",
|
||||
"tray_type": "PLA",
|
||||
"tray_sub_brands": "XXXXX EXAMPLE PLA Example",
|
||||
"tray_color": "FCECD6FF",
|
||||
"nozzle_temp_min": 190,
|
||||
"nozzle_temp_max": 230,
|
||||
"orca_profile_id": ORCA_ID,
|
||||
}
|
||||
params.update(extra)
|
||||
return await async_client.post(f"/api/v1/printers/{printer_id}/slots/0/2/configure", params=params)
|
||||
|
||||
|
||||
class TestConfigureWithOrcaProfile:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_the_profiles_own_filament_id_goes_to_the_slot(self, async_client: AsyncClient, printer_factory):
|
||||
"""The id OrcaSlicer itself sends from its Device tab: Pfc74047 in the
|
||||
reporter's log, where Bambuddy sent GFL99 for the same profile."""
|
||||
printer = await printer_factory(name="H2D")
|
||||
client = _client()
|
||||
lookup = AsyncMock(return_value=OrcaFilamentLookup("Pfc74047", source="own"))
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.printers.printer_manager") as pm,
|
||||
patch("backend.app.api.routes.printers.lookup_orca_filament_id", lookup),
|
||||
):
|
||||
pm.get_client.return_value = client
|
||||
pm.get_status.return_value = _status()
|
||||
response = await _configure(async_client, printer.id)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["tray_info_idx"] == "Pfc74047"
|
||||
assert body["orca_fallback_reason"] == ""
|
||||
assert lookup.await_args.args[2] == ORCA_ID
|
||||
kwargs = client.ams_set_filament_setting.call_args.kwargs
|
||||
assert kwargs["tray_info_idx"] == "Pfc74047"
|
||||
# Never the profile UUID: it is foreign to the printer and to the slicer.
|
||||
assert ORCA_ID not in kwargs["setting_id"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_no_id_falls_back_to_the_generic_and_says_so(self, async_client: AsyncClient, printer_factory):
|
||||
"""Not the slot's previous filament: reusing it would put the previous
|
||||
spool's preset in front of the slicer for a profile the user just chose."""
|
||||
printer = await printer_factory(name="H2D")
|
||||
client = _client()
|
||||
lookup = AsyncMock(return_value=OrcaFilamentLookup(reason="lookup_failed"))
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.printers.printer_manager") as pm,
|
||||
patch("backend.app.api.routes.printers.lookup_orca_filament_id", lookup),
|
||||
):
|
||||
pm.get_client.return_value = client
|
||||
pm.get_status.return_value = _status("P4d64437", "PLA")
|
||||
response = await _configure(async_client, printer.id)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["tray_info_idx"] == "GFL99"
|
||||
assert body["orca_fallback_reason"] == "lookup_failed"
|
||||
assert client.ams_set_filament_setting.call_args.kwargs["tray_info_idx"] == "GFL99"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_an_explicit_filament_id_skips_the_lookup(self, async_client: AsyncClient, printer_factory):
|
||||
printer = await printer_factory(name="H2D")
|
||||
client = _client()
|
||||
lookup = AsyncMock()
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.printers.printer_manager") as pm,
|
||||
patch("backend.app.api.routes.printers.lookup_orca_filament_id", lookup),
|
||||
):
|
||||
pm.get_client.return_value = client
|
||||
pm.get_status.return_value = _status()
|
||||
response = await _configure(async_client, printer.id, tray_info_idx="Pad3f856")
|
||||
|
||||
assert response.status_code == 200
|
||||
lookup.assert_not_awaited()
|
||||
assert client.ams_set_filament_setting.call_args.kwargs["tray_info_idx"] == "Pad3f856"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_other_presets_report_no_fallback(self, async_client: AsyncClient, printer_factory):
|
||||
printer = await printer_factory(name="H2D")
|
||||
client = _client()
|
||||
|
||||
with patch("backend.app.api.routes.printers.printer_manager") as pm:
|
||||
pm.get_client.return_value = client
|
||||
pm.get_status.return_value = _status()
|
||||
response = await _configure(async_client, printer.id, tray_info_idx="GFA00", orca_profile_id="")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["orca_fallback_reason"] == ""
|
||||
assert response.json()["tray_info_idx"] == "GFA00"
|
||||
|
||||
|
||||
class TestOrcaFallbackKeepsTheDialogsGenerics:
|
||||
"""The fallback the Configure dialog used to pick in the browser, kept when
|
||||
the lookup moved to the server: its table has Silk, PCTG, PP and PE."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize(
|
||||
("material", "expected"),
|
||||
[
|
||||
("PLA SILK", "GFL96"),
|
||||
("PCTG", "GFG97"),
|
||||
("PP", "GFP97"),
|
||||
("PETG-CF", "GFG98"),
|
||||
("ASA-CF", "GFB98"),
|
||||
("PLA+", "GFL99"),
|
||||
],
|
||||
)
|
||||
async def test_material_generic(self, async_client: AsyncClient, printer_factory, material, expected):
|
||||
printer = await printer_factory(name="H2D")
|
||||
client = _client()
|
||||
lookup = AsyncMock(return_value=OrcaFilamentLookup(reason="no_filament_id"))
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.printers.printer_manager") as pm,
|
||||
patch("backend.app.api.routes.printers.lookup_orca_filament_id", lookup),
|
||||
):
|
||||
pm.get_client.return_value = client
|
||||
pm.get_status.return_value = _status()
|
||||
response = await _configure(async_client, printer.id, tray_type=material)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert client.ams_set_filament_setting.call_args.kwargs["tray_info_idx"] == expected
|
||||
assert response.json()["orca_fallback_reason"] == "no_filament_id"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_an_unstorable_looked_up_id_is_refused(self, async_client: AsyncClient, printer_factory):
|
||||
"""Same refusal as the #3003 guard: an 18-character PFUS would be
|
||||
stored truncated and resolve nowhere."""
|
||||
printer = await printer_factory(name="H2D")
|
||||
client = _client()
|
||||
lookup = AsyncMock(return_value=OrcaFilamentLookup("PFUS9ac902733670a9", source="own"))
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.printers.printer_manager") as pm,
|
||||
patch("backend.app.api.routes.printers.lookup_orca_filament_id", lookup),
|
||||
):
|
||||
pm.get_client.return_value = client
|
||||
pm.get_status.return_value = _status()
|
||||
response = await _configure(async_client, printer.id)
|
||||
|
||||
assert client.ams_set_filament_setting.call_args.kwargs["tray_info_idx"] == "GFL99"
|
||||
assert response.json()["orca_fallback_reason"] == "no_filament_id"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_kprofile_realignment_clears_the_warning(self, async_client: AsyncClient, printer_factory):
|
||||
"""The slot ends up on the K-profile's own filament, not a generic."""
|
||||
printer = await printer_factory(name="H2D")
|
||||
client = _client()
|
||||
lookup = AsyncMock(return_value=OrcaFilamentLookup(reason="no_filament_id"))
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.printers.printer_manager") as pm,
|
||||
patch("backend.app.api.routes.printers.lookup_orca_filament_id", lookup),
|
||||
):
|
||||
pm.get_client.return_value = client
|
||||
pm.get_status.return_value = _status()
|
||||
response = await _configure(async_client, printer.id, kprofile_filament_id="P4d64437")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["tray_info_idx"] == "P4d64437"
|
||||
assert response.json()["orca_fallback_reason"] == ""
|
||||
|
||||
|
||||
class TestSlotPresetRecordsItsFilamentId:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_saved_and_returned(self, async_client: AsyncClient, printer_factory):
|
||||
printer = await printer_factory(name="H2D")
|
||||
url = f"/api/v1/printers/{printer.id}/slot-presets/0/2"
|
||||
response = await async_client.put(
|
||||
url,
|
||||
params={
|
||||
"preset_id": f"orca_{ORCA_ID}",
|
||||
"preset_name": "XXXXX EXAMPLE PLA Example",
|
||||
"preset_source": "orca_cloud",
|
||||
"tray_info_idx": "Pfc74047",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["tray_info_idx"] == "Pfc74047"
|
||||
|
||||
assert (await async_client.get(url)).json()["tray_info_idx"] == "Pfc74047"
|
||||
listed = (await async_client.get(f"/api/v1/printers/{printer.id}/slot-presets")).json()
|
||||
assert listed["2"]["tray_info_idx"] == "Pfc74047"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_save_without_one_clears_the_old_one(self, async_client: AsyncClient, printer_factory):
|
||||
"""A stale id must not outlive the preset it came with: the slot card
|
||||
would compare the new preset against the old preset's filament."""
|
||||
printer = await printer_factory(name="H2D")
|
||||
url = f"/api/v1/printers/{printer.id}/slot-presets/0/2"
|
||||
await async_client.put(
|
||||
url, params={"preset_id": "GFSL99", "preset_name": "Generic PLA", "tray_info_idx": "GFL99"}
|
||||
)
|
||||
response = await async_client.put(
|
||||
url, params={"preset_id": "local_5", "preset_name": "Mine", "preset_source": "local"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["tray_info_idx"] is None
|
||||
assert (await async_client.get(url)).json()["tray_info_idx"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_an_overlong_id_is_dropped_not_refused(self, async_client: AsyncClient, printer_factory):
|
||||
"""Refusing it would lose the preset save itself, which the dialog only
|
||||
logs; recording nothing keeps the row shown as before."""
|
||||
printer = await printer_factory(name="H2D")
|
||||
url = f"/api/v1/printers/{printer.id}/slot-presets/0/2"
|
||||
response = await async_client.put(url, params={"preset_id": "x", "preset_name": "X", "tray_info_idx": "P" * 40})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["tray_info_idx"] is None
|
||||
@@ -483,3 +483,96 @@ class TestOrcaCloudIsTheFirstSource:
|
||||
)
|
||||
assert idx == ""
|
||||
assert sid == ""
|
||||
|
||||
|
||||
class TestOrcaLookupFollowsParents:
|
||||
"""``lookup_orca_filament_id`` (#3216).
|
||||
|
||||
A profile that inherits carries only its overrides, so its filament id lives
|
||||
on a parent: another of the user's Orca profiles, or a system preset. The
|
||||
parent's id is what OrcaSlicer resolves the slot to, which beats a generic.
|
||||
"""
|
||||
|
||||
ORCA_ID = "34d8f588-860b-5be1-bcbe-c0d46d96324b"
|
||||
|
||||
@staticmethod
|
||||
def _svc(profile, others=()):
|
||||
svc = MagicMock()
|
||||
svc.get_profile = AsyncMock(return_value=profile)
|
||||
svc.list_profiles = AsyncMock(return_value=[profile, *others])
|
||||
svc.close = AsyncMock()
|
||||
return svc
|
||||
|
||||
async def _lookup(self, svc):
|
||||
from backend.app.services.slicer_filament_resolver import lookup_orca_filament_id
|
||||
|
||||
with patch(
|
||||
"backend.app.api.routes.orca_cloud._build_authenticated_service",
|
||||
AsyncMock(return_value=svc),
|
||||
):
|
||||
return await lookup_orca_filament_id(MagicMock(), None, self.ORCA_ID)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_own_id_needs_no_second_pull(self):
|
||||
svc = self._svc({"id": self.ORCA_ID, "name": "Mine", "content": {"filament_id": "Pfc74047", "inherits": ""}})
|
||||
found = await self._lookup(svc)
|
||||
assert (found.filament_id, found.source, found.reason) == ("Pfc74047", "own", "")
|
||||
svc.list_profiles.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inherited_from_a_user_profile(self):
|
||||
child = {"id": self.ORCA_ID, "name": "Mine v2", "content": {"inherits": "Mine v1", "filament_type": ["PLA"]}}
|
||||
parent = {"id": "p", "name": "Mine v1", "content": {"name": "Mine v1", "filament_id": "Pad3f856"}}
|
||||
found = await self._lookup(self._svc(child, [parent]))
|
||||
assert (found.filament_id, found.source) == ("Pad3f856", "inherited")
|
||||
assert found.filament_type == "PLA"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_through_two_user_profiles_to_a_bambu_system_preset(self):
|
||||
child = {"id": self.ORCA_ID, "name": "C", "content": {"inherits": "B"}}
|
||||
middle = {"id": "b", "name": "B", "content": {"inherits": "Bambu PLA Basic @BBL H2D"}}
|
||||
found = await self._lookup(self._svc(child, [middle]))
|
||||
# The catalog id OrcaSlicer translates at the printer boundary.
|
||||
assert (found.filament_id, found.source) == ("GFA00", "inherited")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unknown_system_parent_leaves_the_fallback(self):
|
||||
child = {"id": self.ORCA_ID, "name": "C", "content": {"inherits": "Someone PLA @Somewhere"}}
|
||||
found = await self._lookup(self._svc(child))
|
||||
assert (found.filament_id, found.reason) == ("", "no_filament_id")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_inherits_cycle_terminates(self):
|
||||
a = {"id": self.ORCA_ID, "name": "A", "content": {"inherits": "B"}}
|
||||
b = {"id": "b", "name": "B", "content": {"inherits": "A"}}
|
||||
found = await self._lookup(self._svc(a, [b]))
|
||||
assert (found.filament_id, found.reason) == ("", "no_filament_id")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failed_lookup_says_so(self):
|
||||
svc = MagicMock()
|
||||
svc.get_profile = AsyncMock(side_effect=RuntimeError("network down"))
|
||||
svc.close = AsyncMock()
|
||||
found = await self._lookup(svc)
|
||||
assert (found.filament_id, found.reason) == ("", "lookup_failed")
|
||||
svc.close.assert_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_caller_without_orca_access_is_told_so(self):
|
||||
from backend.app.services.slicer_filament_resolver import lookup_orca_filament_id
|
||||
|
||||
user = MagicMock()
|
||||
user.has_permission.return_value = False
|
||||
found = await lookup_orca_filament_id(MagicMock(), user, self.ORCA_ID)
|
||||
assert found.reason == "no_permission"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failed_parent_pull_keeps_name_and_type(self):
|
||||
"""The spool resolver uses them for the slot's brand and type, as it did
|
||||
before the lookup followed parents."""
|
||||
child = {"id": self.ORCA_ID, "name": "Mine v2", "content": {"inherits": "Mine v1", "filament_type": ["PETG"]}}
|
||||
svc = self._svc(child)
|
||||
svc.list_profiles = AsyncMock(side_effect=RuntimeError("timeout"))
|
||||
found = await self._lookup(svc)
|
||||
assert (found.filament_id, found.reason) == ("", "lookup_failed")
|
||||
assert (found.name, found.filament_type) == ("Mine v2", "PETG")
|
||||
|
||||
@@ -250,22 +250,19 @@ describe('ConfigureAmsSlotModal', () => {
|
||||
expect(payload.setting_id).toBe('PFUScd84f663d2c2ef');
|
||||
});
|
||||
|
||||
it('uses an Orca Cloud profile\'s own filament_id instead of a generic (#3003)', async () => {
|
||||
// Orca profile ids are UUIDs the printer cannot hold, so this branch used
|
||||
// to skip the lookup entirely and send a generic — every Orca custom
|
||||
// filament reached the slicer as "Generic PLA". The profile's slicer JSON
|
||||
// carries a filament_id of exactly the storable shape; use it.
|
||||
it('has the backend resolve an Orca Cloud profile\'s own filament_id (#3003, #3216)', async () => {
|
||||
// OrcaSlicer's "Sync filaments" matches a slot by filament_id alone, so a
|
||||
// generic here turns the profile into "Generic PLA" in the slicer. The
|
||||
// lookup used to run here and fall back silently; it now runs in configure,
|
||||
// which logs the outcome and reports a fallback.
|
||||
const ORCA_ID = '3f2a9c1e-4b7d-4a02-9f61-8c5e2d1a7b30';
|
||||
(api.orcaCloudListProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
filament: [{ setting_id: ORCA_ID, name: 'Overture Matte PLA @Orca', type: 'filament', is_custom: true }],
|
||||
printer: [],
|
||||
process: [],
|
||||
});
|
||||
(api.orcaCloudGetProfile as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
setting_id: ORCA_ID,
|
||||
name: 'Overture Matte PLA @Orca',
|
||||
type: 'filament',
|
||||
setting: { filament_id: 'P56e1be0', filament_type: ['PLA'] },
|
||||
(api.configureAmsSlot as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
success: true, tray_info_idx: 'P56e1be0', orca_fallback_reason: '',
|
||||
});
|
||||
|
||||
const slotInfo = { ...defaultProps.slotInfo, savedPresetId: ORCA_ID };
|
||||
@@ -278,27 +275,32 @@ describe('ConfigureAmsSlotModal', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.configureAmsSlot).toHaveBeenCalled();
|
||||
expect(api.saveSlotPreset).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
|
||||
expect(payload.tray_info_idx).toBe('P56e1be0');
|
||||
expect(payload.orca_profile_id).toBe(ORCA_ID);
|
||||
expect(payload.tray_info_idx).toBe('');
|
||||
// The UUID is what the slicer cannot resolve; it goes in neither field.
|
||||
expect(payload.setting_id).toBe('');
|
||||
expect(api.orcaCloudGetProfile).not.toHaveBeenCalled();
|
||||
// The slot preset row records the id the slot was actually given, so the
|
||||
// slot card notices when the Device tab re-configures the slot.
|
||||
const saveArgs = (api.saveSlotPreset as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(saveArgs[5]).toBe('orca_cloud');
|
||||
expect(saveArgs[6]).toBe('P56e1be0');
|
||||
expect(screen.queryByText(/OrcaSlicer will see this slot as Generic/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to a generic when an Orca profile has no filament_id (#3003)', async () => {
|
||||
it('warns when an Orca profile went into the slot as a generic (#3216)', async () => {
|
||||
const ORCA_ID = '3f2a9c1e-4b7d-4a02-9f61-8c5e2d1a7b30';
|
||||
(api.orcaCloudListProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
filament: [{ setting_id: ORCA_ID, name: 'Homebrew PLA @Orca', type: 'filament', is_custom: true }],
|
||||
printer: [],
|
||||
process: [],
|
||||
});
|
||||
(api.orcaCloudGetProfile as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
setting_id: ORCA_ID,
|
||||
name: 'Homebrew PLA @Orca',
|
||||
type: 'filament',
|
||||
setting: {},
|
||||
(api.configureAmsSlot as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
success: true, tray_info_idx: 'GFL99', orca_fallback_reason: 'no_filament_id',
|
||||
});
|
||||
|
||||
const slotInfo = { ...defaultProps.slotInfo, savedPresetId: ORCA_ID };
|
||||
@@ -310,13 +312,11 @@ describe('ConfigureAmsSlotModal', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.configureAmsSlot).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
|
||||
expect(payload.tray_info_idx).toBe('GFL99');
|
||||
expect(payload.setting_id).toBe('');
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'This Orca profile has no filament ID of its own, so OrcaSlicer will see this slot as Generic PLA.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses the filament_id nested under setting when the envelope has none (#3003)', async () => {
|
||||
@@ -634,6 +634,63 @@ describe('ConfigureAmsSlotModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not carry the slot's K-profile over to a different filament (#3216)", async () => {
|
||||
// X1C slot on Devil Design PLA with its K-profile active, re-configured for
|
||||
// Azurefilm PLA Wood. The active profile used to join Azurefilm's list and
|
||||
// win the auto-select; the backend then realigned the slot to that
|
||||
// profile's filament, so the printer -- and OrcaSlicer -- stayed on Devil
|
||||
// Design. Only a slot reset first made it work.
|
||||
const DEVIL = '0ed901cd-6700-5772-80b2-25ed659df0d1';
|
||||
const AZURE = '2161df52-3d0e-579a-b4b6-dfbc62b93b76';
|
||||
(api.orcaCloudListProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
filament: [
|
||||
{ setting_id: DEVIL, name: 'Devil Design PLA @Orca', type: 'filament', is_custom: true },
|
||||
{ setting_id: AZURE, name: 'Azurefilm PLA Wood @Orca', type: 'filament', is_custom: true },
|
||||
],
|
||||
printer: [],
|
||||
process: [],
|
||||
});
|
||||
const kp = (slot_id: number, name: string, filament_id: string, setting_id: string) => ({
|
||||
slot_id, extruder_id: 0, nozzle_id: 'HS00-0.4', nozzle_diameter: '0.4', filament_id, name,
|
||||
k_value: '0.020', n_coef: '0', ams_id: 0, tray_id: 0, setting_id,
|
||||
});
|
||||
(api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
profiles: [
|
||||
kp(7, 'Devil Design PLA', 'P4d64437', 'PFUSedbf16b803ff3e'),
|
||||
kp(9, 'Azurefilm PLA Wood', 'P285e239', 'PFUS220b72bdc39f90'),
|
||||
],
|
||||
});
|
||||
const slotInfo = {
|
||||
...defaultProps.slotInfo,
|
||||
savedPresetId: `orca_${DEVIL}`,
|
||||
trayInfoIdx: 'P4d64437',
|
||||
caliIdx: 7,
|
||||
extruderId: 0,
|
||||
};
|
||||
render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
|
||||
|
||||
// Reopening on the slot's own filament still shows its active profile.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('option', { name: /Devil Design PLA/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Azurefilm PLA Wood @Orca'));
|
||||
// Azurefilm's own profile is offered and picked; Devil Design's is not.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('option', { name: /Azurefilm PLA Wood/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Configure Slot/i }));
|
||||
await waitFor(() => {
|
||||
expect(api.configureAmsSlot).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const payload = (api.configureAmsSlot as ReturnType<typeof vi.fn>).mock.calls[0][3];
|
||||
expect(payload.orca_profile_id).toBe(AZURE);
|
||||
expect(payload.kprofile_filament_id).toBe('P285e239');
|
||||
expect(payload.cali_idx).toBe(9);
|
||||
});
|
||||
|
||||
it("surfaces the slot's active K-profile when no preset is resolvable (#1689 follow-up)", async () => {
|
||||
// Repro from Spionkiller01: slot is physically loaded but unconfigured —
|
||||
// tray_type='', tray_info_idx='', no slot_preset_mappings row — so
|
||||
|
||||
@@ -67,4 +67,25 @@ describe('slotPresetDescribesTray', () => {
|
||||
expect(slotPresetDescribesTray(undefined, undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with the filament id recorded alongside the row (#3216)', () => {
|
||||
it('keeps an Orca preset while the slot still holds its id', () => {
|
||||
expect(slotPresetDescribesTray('orca_34d8f588-860b-5be1-bcbe-c0d46d96324b', 'Pfc74047', 'Pfc74047')).toBe(true);
|
||||
});
|
||||
|
||||
it('drops it once the slicer\'s Device tab re-configured the slot', () => {
|
||||
// The reporter's case: Bambuddy's row said Generic PVA while the slot
|
||||
// held the Orca filament OrcaSlicer had just written.
|
||||
expect(slotPresetDescribesTray('GFSS99', 'Pfc74047', 'GFS99')).toBe(false);
|
||||
expect(slotPresetDescribesTray('local_68', 'GFA00', 'P56e1be0')).toBe(false);
|
||||
});
|
||||
|
||||
it('compares case-insensitively and without a version suffix', () => {
|
||||
expect(slotPresetDescribesTray('PFUSa3b8b0c664c142', 'P8A85D5A', 'p8a85d5a')).toBe(true);
|
||||
});
|
||||
|
||||
it('cannot judge a slot reporting no id, so keeps the row', () => {
|
||||
expect(slotPresetDescribesTray('local_68', '', 'P56e1be0')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2985,6 +2985,9 @@ export interface SlotPresetMapping {
|
||||
tray_id: number;
|
||||
preset_id: string;
|
||||
preset_name: string;
|
||||
// Filament id the slot was configured with alongside this preset; null for
|
||||
// rows that predate it or whose writer did not know it (#3216).
|
||||
tray_info_idx?: string | null;
|
||||
}
|
||||
|
||||
// Filament types
|
||||
@@ -6422,8 +6425,8 @@ export const api = {
|
||||
request<Record<number, SlotPresetMapping>>(`/printers/${printerId}/slot-presets`),
|
||||
getSlotPreset: (printerId: number, amsId: number, trayId: number) =>
|
||||
request<SlotPresetMapping | null>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`),
|
||||
saveSlotPreset: (printerId: number, amsId: number, trayId: number, presetId: string, presetName: string, presetSource = 'cloud') =>
|
||||
request<SlotPresetMapping>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}?preset_id=${encodeURIComponent(presetId)}&preset_name=${encodeURIComponent(presetName)}&preset_source=${encodeURIComponent(presetSource)}`, {
|
||||
saveSlotPreset: (printerId: number, amsId: number, trayId: number, presetId: string, presetName: string, presetSource = 'cloud', trayInfoIdx?: string) =>
|
||||
request<SlotPresetMapping>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}?preset_id=${encodeURIComponent(presetId)}&preset_name=${encodeURIComponent(presetName)}&preset_source=${encodeURIComponent(presetSource)}${trayInfoIdx ? `&tray_info_idx=${encodeURIComponent(trayInfoIdx)}` : ''}`, {
|
||||
method: 'PUT',
|
||||
}),
|
||||
deleteSlotPreset: (printerId: number, amsId: number, trayId: number) =>
|
||||
@@ -6464,6 +6467,9 @@ export const api = {
|
||||
kprofile_filament_id?: string;
|
||||
kprofile_setting_id?: string;
|
||||
k_value?: number;
|
||||
// Orca Cloud profile the slot is set to; the backend looks up its
|
||||
// filament id when tray_info_idx is empty (#3216).
|
||||
orca_profile_id?: string;
|
||||
}
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
@@ -6488,7 +6494,17 @@ export const api = {
|
||||
if (config.k_value !== undefined && config.k_value > 0) {
|
||||
params.set('k_value', config.k_value.toString());
|
||||
}
|
||||
return request<{ success: boolean; message: string }>(
|
||||
if (config.orca_profile_id) {
|
||||
params.set('orca_profile_id', config.orca_profile_id);
|
||||
}
|
||||
return request<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
// The filament id the slot was actually given.
|
||||
tray_info_idx?: string;
|
||||
// Why an Orca profile went out as the generic for its material, or "".
|
||||
orca_fallback_reason?: '' | 'no_filament_id' | 'lookup_failed' | 'no_permission';
|
||||
}>(
|
||||
`/printers/${printerId}/slots/${amsId}/${trayId}/configure?${params}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from './Button';
|
||||
import { getAmsLabel } from '../utils/amsHelpers';
|
||||
import { getSwatchStyle } from '../utils/colors';
|
||||
import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
interface SlotInfo {
|
||||
amsId: number;
|
||||
@@ -274,7 +275,12 @@ export function ConfigureAmsSlotModal({
|
||||
fullScreen,
|
||||
}: ConfigureAmsSlotModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string>('');
|
||||
// The preset the dialog preselected on open, i.e. what the slot is already
|
||||
// on. Lets the K-profile list tell "reopened the slot" from "picked another
|
||||
// filament" (#3216).
|
||||
const [openedPresetId, setOpenedPresetId] = useState<string>('');
|
||||
const [selectedKProfile, setSelectedKProfile] = useState<KProfile | null>(null);
|
||||
// The same value, readable at mutation-execute time rather than at
|
||||
// closure-capture time. useMutation hands its options to the observer from an
|
||||
@@ -459,8 +465,7 @@ export function ConfigureAmsSlotModal({
|
||||
const parsedMat = parsed.material.toUpperCase();
|
||||
|
||||
// Generic Bambu filament-ID map used to derive a ``tray_info_idx`` for
|
||||
// presets that don't carry a Bambu setting_id of their own (local
|
||||
// imports and Orca Cloud sync both fall in this bucket). The printer's
|
||||
// local imports, which carry no Bambu setting_id of their own. The printer's
|
||||
// firmware needs SOMETHING in tray_info_idx to recognize the filament
|
||||
// type for HMS / drying / colour-matching; the closest generic Bambu
|
||||
// filament for the parsed material is the right choice.
|
||||
@@ -487,40 +492,16 @@ export function ConfigureAmsSlotModal({
|
||||
|| '';
|
||||
settingId = '';
|
||||
} else if (isOrca) {
|
||||
// An Orca Cloud profile's setting_id is a UUID the printer cannot hold,
|
||||
// so this used to skip straight to a generic — which meant every Orca
|
||||
// custom filament reached the slicer as "Generic <material>", without
|
||||
// ever asking whether the profile had a usable id (#3003).
|
||||
// The slot needs the profile's own filament_id: it is the one value
|
||||
// OrcaSlicer's "Sync filaments" matches a slot by, so anything else
|
||||
// reaches the slicer as "Generic <material>" (#3003, #3216). The
|
||||
// backend looks it up from orca_profile_id -- following the profile's
|
||||
// parents when it has none of its own -- logs the outcome, and falls
|
||||
// back to the generic for the material only when there is none.
|
||||
//
|
||||
// It usually does. The profile's slicer JSON carries its own
|
||||
// filament_id, the same 8-character "P…" the printer stores for a
|
||||
// Bambu custom preset, and OrcaProfileDetail deliberately exposes that
|
||||
// JSON under `setting` in the shape SlicerSettingDetail uses. So the
|
||||
// lookup is the same one the Bambu Cloud branch does below.
|
||||
//
|
||||
// settingId stays empty either way: it is the UUID that is foreign to
|
||||
// the slicer, not the filament id.
|
||||
let orcaFilamentId = '';
|
||||
try {
|
||||
const orcaDetail = await api.orcaCloudGetProfile(orcaSettingId);
|
||||
const fromProfile = orcaDetail.setting?.filament_id;
|
||||
if (typeof fromProfile === 'string' && fromProfile) {
|
||||
orcaFilamentId = fromProfile;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch Orca Cloud profile for filament_id:', e);
|
||||
}
|
||||
trayInfoIdx = orcaFilamentId;
|
||||
if (!trayInfoIdx) {
|
||||
// No id of its own — fall back to the generic for the material, the
|
||||
// same last resort every other source ends at.
|
||||
const material = (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : parsed.material || '').toUpperCase();
|
||||
trayInfoIdx = GENERIC_IDS[material]
|
||||
|| GENERIC_IDS[material.replace(/[-\s]?CF$/, '')]
|
||||
|| GENERIC_IDS[material.replace(/\+$/, '')]
|
||||
|| GENERIC_IDS[material.split(/[-\s]/)[0]]
|
||||
|| '';
|
||||
}
|
||||
// settingId stays empty: the profile id is a UUID, foreign to the
|
||||
// printer and to the slicer's slot matching alike.
|
||||
trayInfoIdx = '';
|
||||
settingId = '';
|
||||
} else if (isBuiltin) {
|
||||
// Built-in presets use the filament_id directly as tray_info_idx
|
||||
@@ -636,8 +617,16 @@ export function ConfigureAmsSlotModal({
|
||||
kprofile_setting_id: selectedKProfileRef.current?.setting_id || undefined,
|
||||
// Also pass the K value directly for extrusion_cali_set command
|
||||
k_value: kValue,
|
||||
orca_profile_id: isOrca ? orcaSettingId : undefined,
|
||||
});
|
||||
|
||||
if (isOrca && result.orca_fallback_reason) {
|
||||
showToast(
|
||||
t(`configureAmsSlot.orcaFallback.${result.orca_fallback_reason}`, { material: trayType }),
|
||||
'warning',
|
||||
);
|
||||
}
|
||||
|
||||
// Save the preset mapping so we can display the correct name in the UI
|
||||
// This is needed because user presets use filament_id (e.g., P285e239) as tray_info_idx,
|
||||
// which can't be resolved to a name via the filamentInfo API
|
||||
@@ -656,7 +645,9 @@ export function ConfigureAmsSlotModal({
|
||||
? 'orca_cloud'
|
||||
: 'cloud';
|
||||
try {
|
||||
await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, mappingPresetId, traySubBrands, mappingSource);
|
||||
// The filament id goes with it, so the slot card can tell when the slot
|
||||
// is re-configured from elsewhere and stop showing this preset (#3216).
|
||||
await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, mappingPresetId, traySubBrands, mappingSource, result.tray_info_idx);
|
||||
} catch (e) {
|
||||
console.warn('Failed to save slot preset mapping:', e);
|
||||
// Don't fail the whole operation - slot was configured successfully
|
||||
@@ -1038,14 +1029,23 @@ export function ConfigureAmsSlotModal({
|
||||
// the printer whose filament_id differs from "Generic PLA"; without this
|
||||
// safety net the modal shows "not assigned, default 0.020" while the printer
|
||||
// card's hover-card correctly shows the active profile.
|
||||
//
|
||||
// Only while the selected preset is still the slot's own, though: the
|
||||
// active profile belongs to whatever filament the slot is on now. Offered
|
||||
// for a different filament, the auto-select below picked it, and the
|
||||
// backend then realigned the slot to that profile's filament -- so
|
||||
// configuring Azurefilm on a slot that held Devil Design re-sent Devil
|
||||
// Design (#3216).
|
||||
const activeIdx = slotInfo.caliIdx;
|
||||
if (activeIdx != null && activeIdx > 0 && !result.some(p => p.slot_id === activeIdx)) {
|
||||
const onSlotsOwnPreset = (!!openedPresetId && selectedPresetId === openedPresetId)
|
||||
|| (!!presetFid && presetFid === toFilamentId(slotInfo.trayInfoIdx));
|
||||
if (onSlotsOwnPreset && activeIdx != null && activeIdx > 0 && !result.some(p => p.slot_id === activeIdx)) {
|
||||
const active = findProfileByCaliIdx(kprofilesData.profiles, activeIdx, slotInfo.extruderId);
|
||||
if (active) result.unshift(active);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId, slotInfo.caliIdx]);
|
||||
}, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId, slotInfo.caliIdx, slotInfo.trayInfoIdx, selectedPresetId, openedPresetId]);
|
||||
|
||||
// Every remaining K-profile the printer holds, offered under a separate group
|
||||
// after the matching ones (#2710). The matcher works off preset names and
|
||||
@@ -1088,8 +1088,10 @@ export function ConfigureAmsSlotModal({
|
||||
// The spool's own per-model preset first -- see the query above.
|
||||
if (slotSpoolDefaults?.slicer_filament) {
|
||||
setSelectedPresetId(slotSpoolDefaults.slicer_filament);
|
||||
setOpenedPresetId(slotSpoolDefaults.slicer_filament);
|
||||
} else if (slotInfo.savedPresetId) {
|
||||
setSelectedPresetId(slotInfo.savedPresetId);
|
||||
setOpenedPresetId(slotInfo.savedPresetId);
|
||||
} else if (slotInfo.trayInfoIdx && cloudSettings?.filament) {
|
||||
// Fallback: try to match by tray_info_idx in cloud presets
|
||||
// First try exact match on setting_id
|
||||
@@ -1104,6 +1106,7 @@ export function ConfigureAmsSlotModal({
|
||||
}
|
||||
if (currentPreset) {
|
||||
setSelectedPresetId(currentPreset.setting_id);
|
||||
setOpenedPresetId(currentPreset.setting_id);
|
||||
}
|
||||
} else if (slotInfo.trayInfoIdx && builtinFilaments?.length) {
|
||||
// Last resort: match trayInfoIdx against builtin presets
|
||||
@@ -1111,6 +1114,7 @@ export function ConfigureAmsSlotModal({
|
||||
const match = builtinFilaments.find(bf => bf.filament_id === trayIdx);
|
||||
if (match) {
|
||||
setSelectedPresetId(`builtin_${match.filament_id}`);
|
||||
setOpenedPresetId(`builtin_${match.filament_id}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1124,6 +1128,7 @@ export function ConfigureAmsSlotModal({
|
||||
} else {
|
||||
// Reset when modal closes
|
||||
setSelectedPresetId('');
|
||||
setOpenedPresetId('');
|
||||
setSelectedKProfile(null);
|
||||
setColorHex('');
|
||||
setColorInput('');
|
||||
|
||||
@@ -6861,6 +6861,12 @@ export default {
|
||||
cancel: 'Abbrechen',
|
||||
configuring: 'Wird konfiguriert...',
|
||||
configureSlot: 'Slot konfigurieren',
|
||||
// Why an Orca profile went into the slot as the generic for its material (#3216)
|
||||
orcaFallback: {
|
||||
no_filament_id: 'Dieses Orca-Profil hat keine eigene Filament-ID, daher sieht OrcaSlicer diesen Slot als Generic {{material}}.',
|
||||
lookup_failed: 'Die Filament-ID dieses Profils konnte nicht aus der Orca Cloud gelesen werden, daher sieht OrcaSlicer diesen Slot als Generic {{material}}. Versuche es gleich noch einmal.',
|
||||
no_permission: 'Du hast keinen Zugriff auf die Orca Cloud, daher konnte die Filament-ID dieses Profils nicht gelesen werden. OrcaSlicer sieht diesen Slot als Generic {{material}}.',
|
||||
},
|
||||
},
|
||||
|
||||
// Git Backup Settings
|
||||
|
||||
@@ -6912,6 +6912,12 @@ export default {
|
||||
cancel: 'Cancel',
|
||||
configuring: 'Configuring...',
|
||||
configureSlot: 'Configure Slot',
|
||||
// Why an Orca profile went into the slot as the generic for its material (#3216)
|
||||
orcaFallback: {
|
||||
no_filament_id: 'This Orca profile has no filament ID of its own, so OrcaSlicer will see this slot as Generic {{material}}.',
|
||||
lookup_failed: 'Couldn\'t read this profile\'s filament ID from Orca Cloud, so OrcaSlicer will see this slot as Generic {{material}}. Try again in a moment.',
|
||||
no_permission: 'You don\'t have Orca Cloud access, so this profile\'s filament ID couldn\'t be read. OrcaSlicer will see this slot as Generic {{material}}.',
|
||||
},
|
||||
},
|
||||
|
||||
// Git Backup Settings
|
||||
|
||||
@@ -6869,6 +6869,12 @@ export default {
|
||||
cancel: 'Cancelar',
|
||||
configuring: 'Configurando...',
|
||||
configureSlot: 'Configurar ranura',
|
||||
// Why an Orca profile went into the slot as the generic for its material (#3216)
|
||||
orcaFallback: {
|
||||
no_filament_id: 'Este perfil de Orca no tiene un ID de filamento propio, así que OrcaSlicer verá esta ranura como Generic {{material}}.',
|
||||
lookup_failed: 'No se pudo leer el ID de filamento de este perfil desde Orca Cloud, así que OrcaSlicer verá esta ranura como Generic {{material}}. Inténtalo de nuevo en un momento.',
|
||||
no_permission: 'No tienes acceso a Orca Cloud, así que no se pudo leer el ID de filamento de este perfil. OrcaSlicer verá esta ranura como Generic {{material}}.',
|
||||
},
|
||||
},
|
||||
|
||||
// Git Backup Settings
|
||||
|
||||
@@ -6849,6 +6849,12 @@ export default {
|
||||
cancel: 'Annuler',
|
||||
configuring: 'Configuration...',
|
||||
configureSlot: 'Configurer le slot',
|
||||
// Why an Orca profile went into the slot as the generic for its material (#3216)
|
||||
orcaFallback: {
|
||||
no_filament_id: 'Ce profil Orca n’a pas d’identifiant de filament propre : OrcaSlicer verra cet emplacement comme Generic {{material}}.',
|
||||
lookup_failed: 'Impossible de lire l’identifiant de filament de ce profil depuis Orca Cloud : OrcaSlicer verra cet emplacement comme Generic {{material}}. Réessayez dans un instant.',
|
||||
no_permission: 'Vous n’avez pas accès à Orca Cloud, l’identifiant de filament de ce profil n’a donc pas pu être lu. OrcaSlicer verra cet emplacement comme Generic {{material}}.',
|
||||
},
|
||||
},
|
||||
|
||||
// Git Backup Settings
|
||||
|
||||
@@ -6848,6 +6848,12 @@ export default {
|
||||
cancel: 'Annulla',
|
||||
configuring: 'Configurazione...',
|
||||
configureSlot: 'Configura slot',
|
||||
// Why an Orca profile went into the slot as the generic for its material (#3216)
|
||||
orcaFallback: {
|
||||
no_filament_id: 'Questo profilo Orca non ha un ID filamento proprio, quindi OrcaSlicer vedrà questo slot come Generic {{material}}.',
|
||||
lookup_failed: 'Impossibile leggere l’ID filamento di questo profilo da Orca Cloud, quindi OrcaSlicer vedrà questo slot come Generic {{material}}. Riprova tra poco.',
|
||||
no_permission: 'Non hai accesso a Orca Cloud, quindi non è stato possibile leggere l’ID filamento di questo profilo. OrcaSlicer vedrà questo slot come Generic {{material}}.',
|
||||
},
|
||||
},
|
||||
|
||||
// Git Backup Settings
|
||||
|
||||
@@ -6862,6 +6862,12 @@ export default {
|
||||
cancel: 'キャンセル',
|
||||
configuring: '設定中...',
|
||||
configureSlot: 'スロットを設定',
|
||||
// Why an Orca profile went into the slot as the generic for its material (#3216)
|
||||
orcaFallback: {
|
||||
no_filament_id: 'このOrcaプロファイルには独自のフィラメントIDがないため、OrcaSlicerではこのスロットがGeneric {{material}}として表示されます。',
|
||||
lookup_failed: 'Orca CloudからこのプロファイルのフィラメントIDを読み取れなかったため、OrcaSlicerではこのスロットがGeneric {{material}}として表示されます。しばらくしてから再試行してください。',
|
||||
no_permission: 'Orca Cloudへのアクセス権がないため、このプロファイルのフィラメントIDを読み取れませんでした。OrcaSlicerではこのスロットがGeneric {{material}}として表示されます。',
|
||||
},
|
||||
},
|
||||
|
||||
// Git Backup Settings
|
||||
|
||||
@@ -6546,7 +6546,13 @@ export default {
|
||||
resetSlot: '슬롯 초기화',
|
||||
cancel: '취소',
|
||||
configuring: '구성 중...',
|
||||
configureSlot: '슬롯 구성'
|
||||
configureSlot: '슬롯 구성',
|
||||
// Why an Orca profile went into the slot as the generic for its material (#3216)
|
||||
orcaFallback: {
|
||||
no_filament_id: '이 Orca 프로필에는 자체 필라멘트 ID가 없어 OrcaSlicer에서 이 슬롯이 Generic {{material}}(으)로 표시됩니다.',
|
||||
lookup_failed: 'Orca Cloud에서 이 프로필의 필라멘트 ID를 읽지 못해 OrcaSlicer에서 이 슬롯이 Generic {{material}}(으)로 표시됩니다. 잠시 후 다시 시도하세요.',
|
||||
no_permission: 'Orca Cloud 접근 권한이 없어 이 프로필의 필라멘트 ID를 읽을 수 없었습니다. OrcaSlicer에서 이 슬롯이 Generic {{material}}(으)로 표시됩니다.',
|
||||
},
|
||||
},
|
||||
githubBackup: {
|
||||
title: 'Git 백업',
|
||||
|
||||
@@ -6912,6 +6912,12 @@ export default {
|
||||
cancel: 'Annuleren',
|
||||
configuring: 'Configureren...',
|
||||
configureSlot: 'Sleuf configureren',
|
||||
// Why an Orca profile went into the slot as the generic for its material (#3216)
|
||||
orcaFallback: {
|
||||
no_filament_id: 'Dit Orca-profiel heeft geen eigen filament-ID, dus OrcaSlicer ziet deze sleuf als Generic {{material}}.',
|
||||
lookup_failed: 'De filament-ID van dit profiel kon niet uit Orca Cloud worden gelezen, dus OrcaSlicer ziet deze sleuf als Generic {{material}}. Probeer het zo opnieuw.',
|
||||
no_permission: 'Je hebt geen toegang tot Orca Cloud, dus de filament-ID van dit profiel kon niet worden gelezen. OrcaSlicer ziet deze sleuf als Generic {{material}}.',
|
||||
},
|
||||
},
|
||||
|
||||
// Git Backup Settings
|
||||
|
||||
@@ -6848,6 +6848,12 @@ export default {
|
||||
cancel: 'Cancelar',
|
||||
configuring: 'Configurando...',
|
||||
configureSlot: 'Configurar Slot',
|
||||
// Why an Orca profile went into the slot as the generic for its material (#3216)
|
||||
orcaFallback: {
|
||||
no_filament_id: 'Este perfil do Orca não tem um ID de filamento próprio, então o OrcaSlicer verá este slot como Generic {{material}}.',
|
||||
lookup_failed: 'Não foi possível ler o ID de filamento deste perfil no Orca Cloud, então o OrcaSlicer verá este slot como Generic {{material}}. Tente novamente em instantes.',
|
||||
no_permission: 'Você não tem acesso ao Orca Cloud, então o ID de filamento deste perfil não pôde ser lido. O OrcaSlicer verá este slot como Generic {{material}}.',
|
||||
},
|
||||
},
|
||||
|
||||
// Git Backup Settings
|
||||
|
||||
@@ -6534,6 +6534,12 @@ export default {
|
||||
cancel: "Отмена",
|
||||
configuring: "Настройка...",
|
||||
configureSlot: "Настроить слот",
|
||||
// Why an Orca profile went into the slot as the generic for its material (#3216)
|
||||
orcaFallback: {
|
||||
no_filament_id: 'У этого профиля Orca нет собственного ID филамента, поэтому OrcaSlicer увидит этот слот как Generic {{material}}.',
|
||||
lookup_failed: 'Не удалось прочитать ID филамента этого профиля из Orca Cloud, поэтому OrcaSlicer увидит этот слот как Generic {{material}}. Повторите попытку чуть позже.',
|
||||
no_permission: 'У вас нет доступа к Orca Cloud, поэтому ID филамента этого профиля прочитать не удалось. OrcaSlicer увидит этот слот как Generic {{material}}.',
|
||||
},
|
||||
},
|
||||
githubBackup: {
|
||||
title: "Резервное копирование в Git",
|
||||
|
||||
@@ -6910,6 +6910,12 @@ errors: {
|
||||
cancel: 'Avbryt',
|
||||
configuring: 'Konfigurerar...',
|
||||
configureSlot: 'Konfigurera fack',
|
||||
// Why an Orca profile went into the slot as the generic for its material (#3216)
|
||||
orcaFallback: {
|
||||
no_filament_id: 'Den här Orca-profilen har inget eget filament-ID, så OrcaSlicer kommer att se den här platsen som Generic {{material}}.',
|
||||
lookup_failed: 'Det gick inte att läsa profilens filament-ID från Orca Cloud, så OrcaSlicer kommer att se den här platsen som Generic {{material}}. Försök igen om en stund.',
|
||||
no_permission: 'Du har inte åtkomst till Orca Cloud, så profilens filament-ID kunde inte läsas. OrcaSlicer kommer att se den här platsen som Generic {{material}}.',
|
||||
},
|
||||
},
|
||||
|
||||
// Git Backup Settings
|
||||
|
||||
@@ -6803,6 +6803,12 @@ export default {
|
||||
cancel: 'İptal',
|
||||
configuring: 'Yapılandırılıyor...',
|
||||
configureSlot: 'Yuvayı Yapılandır',
|
||||
// Why an Orca profile went into the slot as the generic for its material (#3216)
|
||||
orcaFallback: {
|
||||
no_filament_id: 'Bu Orca profilinin kendine ait bir filament kimliği yok, bu yüzden OrcaSlicer bu yuvayı Generic {{material}} olarak görecek.',
|
||||
lookup_failed: 'Bu profilin filament kimliği Orca Cloud\'dan okunamadı, bu yüzden OrcaSlicer bu yuvayı Generic {{material}} olarak görecek. Birazdan tekrar deneyin.',
|
||||
no_permission: 'Orca Cloud erişiminiz yok, bu yüzden bu profilin filament kimliği okunamadı. OrcaSlicer bu yuvayı Generic {{material}} olarak görecek.',
|
||||
},
|
||||
},
|
||||
|
||||
// Git Yedekleme Ayarları
|
||||
|
||||
@@ -6902,6 +6902,12 @@ export default {
|
||||
cancel: "Скасувати",
|
||||
configuring: "Налаштування...",
|
||||
configureSlot: "Налаштувати слот",
|
||||
// Why an Orca profile went into the slot as the generic for its material (#3216)
|
||||
orcaFallback: {
|
||||
no_filament_id: 'Цей профіль Orca не має власного ID філаменту, тому OrcaSlicer бачитиме цей слот як Generic {{material}}.',
|
||||
lookup_failed: 'Не вдалося прочитати ID філаменту цього профілю з Orca Cloud, тому OrcaSlicer бачитиме цей слот як Generic {{material}}. Спробуйте ще раз за хвилину.',
|
||||
no_permission: 'У вас немає доступу до Orca Cloud, тому ID філаменту цього профілю не вдалося прочитати. OrcaSlicer бачитиме цей слот як Generic {{material}}.',
|
||||
},
|
||||
},
|
||||
|
||||
// Git Backup Settings
|
||||
|
||||
@@ -6846,6 +6846,12 @@ export default {
|
||||
cancel: '取消',
|
||||
configuring: '配置中...',
|
||||
configureSlot: '配置槽位',
|
||||
// Why an Orca profile went into the slot as the generic for its material (#3216)
|
||||
orcaFallback: {
|
||||
no_filament_id: '此 Orca 配置文件没有自己的耗材 ID,因此 OrcaSlicer 会将此槽位识别为 Generic {{material}}。',
|
||||
lookup_failed: '无法从 Orca Cloud 读取此配置文件的耗材 ID,因此 OrcaSlicer 会将此槽位识别为 Generic {{material}}。请稍后重试。',
|
||||
no_permission: '你没有 Orca Cloud 访问权限,因此无法读取此配置文件的耗材 ID。OrcaSlicer 会将此槽位识别为 Generic {{material}}。',
|
||||
},
|
||||
},
|
||||
|
||||
// Git Backup Settings
|
||||
|
||||
@@ -6846,6 +6846,12 @@ export default {
|
||||
cancel: '取消',
|
||||
configuring: '設定中...',
|
||||
configureSlot: '設定槽位',
|
||||
// Why an Orca profile went into the slot as the generic for its material (#3216)
|
||||
orcaFallback: {
|
||||
no_filament_id: '此 Orca 設定檔沒有自己的線材 ID,因此 OrcaSlicer 會將此槽位識別為 Generic {{material}}。',
|
||||
lookup_failed: '無法從 Orca Cloud 讀取此設定檔的線材 ID,因此 OrcaSlicer 會將此槽位識別為 Generic {{material}}。請稍後再試。',
|
||||
no_permission: '你沒有 Orca Cloud 存取權限,因此無法讀取此設定檔的線材 ID。OrcaSlicer 會將此槽位識別為 Generic {{material}}。',
|
||||
},
|
||||
},
|
||||
|
||||
// Git Backup Settings
|
||||
|
||||
@@ -5525,7 +5525,7 @@ function PrinterCard({
|
||||
// Only trusted while it still describes what the printer reports in the
|
||||
// slot: the row survives a spool swap, and the display chain below puts
|
||||
// it ahead of the live filament id (see slotPresetDescribesTray).
|
||||
const slotPresetName = slotPresetDescribesTray(slotPreset?.preset_id, tray?.tray_info_idx)
|
||||
const slotPresetName = slotPresetDescribesTray(slotPreset?.preset_id, tray?.tray_info_idx, slotPreset?.tray_info_idx)
|
||||
? slotPreset?.preset_name
|
||||
: undefined;
|
||||
|
||||
@@ -5775,7 +5775,7 @@ function PrinterCard({
|
||||
trayInfoIdx: tray?.tray_info_idx || undefined,
|
||||
extruderId: resolveSlotExtruder(ams.id, tray?.id ?? 0, amsExtruderMap, amsSwitchInlet),
|
||||
caliIdx: tray?.cali_idx,
|
||||
savedPresetId: slotPreset?.preset_id,
|
||||
savedPresetId: slotPresetDescribesTray(slotPreset?.preset_id, tray?.tray_info_idx, slotPreset?.tray_info_idx) ? slotPreset?.preset_id : undefined,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
@@ -5842,7 +5842,7 @@ function PrinterCard({
|
||||
// Only trusted while it still describes what the printer reports in the
|
||||
// slot: the row survives a spool swap, and the display chain below puts
|
||||
// it ahead of the live filament id (see slotPresetDescribesTray).
|
||||
const slotPresetName = slotPresetDescribesTray(slotPreset?.preset_id, tray?.tray_info_idx)
|
||||
const slotPresetName = slotPresetDescribesTray(slotPreset?.preset_id, tray?.tray_info_idx, slotPreset?.tray_info_idx)
|
||||
? slotPreset?.preset_name
|
||||
: undefined;
|
||||
const htSlotId = tray?.id ?? 0;
|
||||
@@ -6191,7 +6191,7 @@ function PrinterCard({
|
||||
trayInfoIdx: tray?.tray_info_idx || undefined,
|
||||
extruderId: resolveSlotExtruder(ams.id, tray?.id ?? 0, amsExtruderMap, amsSwitchInlet),
|
||||
caliIdx: tray?.cali_idx,
|
||||
savedPresetId: slotPreset?.preset_id,
|
||||
savedPresetId: slotPresetDescribesTray(slotPreset?.preset_id, tray?.tray_info_idx, slotPreset?.tray_info_idx) ? slotPreset?.preset_id : undefined,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
@@ -6292,7 +6292,7 @@ function PrinterCard({
|
||||
// Only trusted while it still describes what the printer reports in the
|
||||
// slot: the row survives a spool swap, and the display chain below puts
|
||||
// it ahead of the live filament id (see slotPresetDescribesTray).
|
||||
const extSlotPresetName = slotPresetDescribesTray(extSlotPreset?.preset_id, extTray.tray_info_idx)
|
||||
const extSlotPresetName = slotPresetDescribesTray(extSlotPreset?.preset_id, extTray.tray_info_idx, extSlotPreset?.tray_info_idx)
|
||||
? extSlotPreset?.preset_name
|
||||
: undefined;
|
||||
|
||||
@@ -6483,7 +6483,7 @@ function PrinterCard({
|
||||
trayInfoIdx: extTray.tray_info_idx || undefined,
|
||||
extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,
|
||||
caliIdx: extTray.cali_idx,
|
||||
savedPresetId: extSlotPreset?.preset_id,
|
||||
savedPresetId: slotPresetDescribesTray(extSlotPreset?.preset_id, extTray.tray_info_idx, extSlotPreset?.tray_info_idx) ? extSlotPreset?.preset_id : undefined,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Layers, Settings2, Package, Unlink, Link2, X } from 'lucide-react';
|
||||
import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
|
||||
import { api } from '../../api/client';
|
||||
import type { PrinterStatus, AMSTray, SpoolAssignment } from '../../api/client';
|
||||
import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, formatSlotLabel, isBambuLabSpool, resolveSlotNozzleDiameter } from '../../utils/amsHelpers';
|
||||
import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, formatSlotLabel, isBambuLabSpool, resolveSlotNozzleDiameter, slotPresetDescribesTray } from '../../utils/amsHelpers';
|
||||
import { getSwatchStyle, resolveSpoolColorName } from '../../utils/colors';
|
||||
import { mapModelCode } from '../../utils/printerModel';
|
||||
import { spoolSwatchStyle } from '../../components/spoolbuddy/spoolPaint';
|
||||
@@ -393,7 +393,10 @@ export function SpoolBuddyAmsPage() {
|
||||
trayInfoIdx: tray?.tray_info_idx || undefined,
|
||||
extruderId: isDualNozzle ? extruderId : undefined,
|
||||
caliIdx: tray?.cali_idx,
|
||||
savedPresetId: slotPreset?.preset_id,
|
||||
// Only while it still describes the slot (#3216).
|
||||
savedPresetId: slotPresetDescribesTray(slotPreset?.preset_id, tray?.tray_info_idx, slotPreset?.tray_info_idx)
|
||||
? slotPreset?.preset_id
|
||||
: undefined,
|
||||
location: `${getAmsName(amsId)} Slot ${trayId + 1}`,
|
||||
};
|
||||
|
||||
@@ -415,7 +418,9 @@ export function SpoolBuddyAmsPage() {
|
||||
trayInfoIdx: extTray.tray_info_idx || undefined,
|
||||
extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,
|
||||
caliIdx: extTray.cali_idx,
|
||||
savedPresetId: extSlotPreset?.preset_id,
|
||||
savedPresetId: slotPresetDescribesTray(extSlotPreset?.preset_id, extTray.tray_info_idx, extSlotPreset?.tray_info_idx)
|
||||
? extSlotPreset?.preset_id
|
||||
: undefined,
|
||||
location: isDualNozzle
|
||||
? (extTrayId === 254 ? 'Ext-L' : 'Ext-R')
|
||||
: 'External',
|
||||
|
||||
@@ -675,13 +675,22 @@ export function isBambuLabSpool(tray: {
|
||||
* and a local preset (`local_68`) has no printer-side id at all, so neither can
|
||||
* be checked here and both keep the stored name. Same for a slot reporting no
|
||||
* id (generic filament with no tag), which is the case the row exists for.
|
||||
*
|
||||
* Rows written since #3216 also record the filament id the slot was given
|
||||
* with the preset (`presetTrayInfoIdx`). That settles every kind of preset:
|
||||
* once the printer reports a different id, something else — the slicer's
|
||||
* Device tab, the printer's screen — re-configured the slot, and the row no
|
||||
* longer describes it.
|
||||
*/
|
||||
export function slotPresetDescribesTray(
|
||||
presetId: string | null | undefined,
|
||||
trayInfoIdx: string | null | undefined,
|
||||
presetTrayInfoIdx?: string | null,
|
||||
): boolean {
|
||||
const preset = (presetId || '').split('_')[0].toUpperCase();
|
||||
const tray = (trayInfoIdx || '').split('_')[0].toUpperCase();
|
||||
const recorded = (presetTrayInfoIdx || '').split('_')[0].toUpperCase();
|
||||
if (recorded && tray) return recorded === tray;
|
||||
if (!preset.startsWith('GFS') || !tray.startsWith('GF') || tray.startsWith('GFS')) return true;
|
||||
return `GF${preset.slice(3)}` === tray;
|
||||
}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{a as t,c as n,d as r,g as i,i as a,l as o,n as s,o as c,r as l,s as u,t as d,u as f,v as p,y as m}from"./index-7qfiHY5t.js";var h=e(m(),1),g=i(),_=.4,v=8,y=1.25,b=30,x=Math.log(y)/b,S=e=>Math.min(v,Math.max(_,e)),C={x:0,y:0};function w(e){return e.deltaMode===1?e.deltaY*16:e.deltaMode===2?e.deltaY*100:e.deltaY}function T({libraryFileId:e,filename:i,fileSize:m,onClose:_}){let{t:v}=p(),T=d(),{isFullscreen:E,toggleFullscreen:D}=T,O=(0,h.useRef)(null),k=(0,h.useRef)(null),[A,j]=(0,h.useState)(null),[M,N]=(0,h.useState)(!1),[P,F]=(0,h.useState)(null),[I,L]=(0,h.useState)(1),[R,z]=(0,h.useState)(C),[B,V]=(0,h.useState)(!1),H=(0,h.useRef)(1),U=(0,h.useRef)(C),W=(0,h.useRef)(new Map),G=(0,h.useRef)(null),K=(0,h.useCallback)((e,t)=>{let n=O.current,r=k.current;if(!n||!r)return e;let i=Math.max(0,(r.offsetWidth*t-n.clientWidth)/2),a=Math.max(0,(r.offsetHeight*t-n.clientHeight)/2);return{x:Math.min(i,Math.max(-i,e.x)),y:Math.min(a,Math.max(-a,e.y))}},[]),q=(0,h.useCallback)((e,t)=>{H.current=e,U.current=t,L(e),z(t)},[]),J=(0,h.useCallback)((e,t,n)=>{let r=H.current,i=S(r*e);if(i===r)return;let a=O.current,o=U.current,s={x:o.x*(i/r),y:o.y*(i/r)};if(a){let e=a.getBoundingClientRect(),c=(t??e.left+e.width/2)-(e.left+e.width/2),l=(n??e.top+e.height/2)-(e.top+e.height/2),u=i/r;s={x:c-(c-o.x)*u,y:l-(l-o.y)*u}}q(i,K(s,i))},[K,q]),Y=(0,h.useCallback)(()=>q(1,C),[q]);(0,h.useEffect)(()=>{let n=!1,r=null;if(j(null),N(!1),F(null),q(1,C),m>52428800){F(v(`fileManager.preview.tooLarge`,{size:a(m)}));return}let i={},o=c();return o&&(i.Authorization=`Bearer ${o}`),(async()=>{let a=await fetch(t.getLibraryFileDownloadUrl(e),{headers:i});if(!a.ok)throw Error(`HTTP ${a.status}`);let o=await a.blob();n||(r=URL.createObjectURL(o),j(r))})().catch(e=>{console.error(`[image-preview] load failed`,e),n||F(v(`fileManager.preview.error`))}),()=>{n=!0,r&&URL.revokeObjectURL(r)}},[e,m,v,q]),(0,h.useEffect)(()=>{let e=e=>{e.ctrlKey||e.metaKey||e.altKey||(e.key===`+`||e.key===`=`?(e.preventDefault(),J(y)):e.key===`-`?(e.preventDefault(),J(1/y)):e.key===`0`&&(e.preventDefault(),Y()))};return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[Y,J]),(0,h.useEffect)(()=>{let e=O.current;if(!e)return;let t=e=>{e.preventDefault();let t=Math.max(-30,Math.min(b,w(e)));J(Math.exp(-t*x),e.clientX,e.clientY)};return e.addEventListener(`wheel`,t,{passive:!1}),()=>e.removeEventListener(`wheel`,t)},[J,A]);let X=e=>{W.current.set(e.pointerId,{x:e.clientX,y:e.clientY}),G.current=null,W.current.size===1&&H.current>1&&(e.currentTarget.setPointerCapture(e.pointerId),V(!0))},Z=e=>{W.current.delete(e.pointerId),G.current=null,W.current.size===0&&V(!1)},Q=e=>{let t=W.current,n=t.get(e.pointerId);if(!n)return;if(e.pointerType===`mouse`&&e.buttons===0){Z(e);return}if(t.set(e.pointerId,{x:e.clientX,y:e.clientY}),t.size===1){if(H.current<=1)return;let t={x:U.current.x+(e.clientX-n.x),y:U.current.y+(e.clientY-n.y)};q(H.current,K(t,H.current));return}if(t.size!==2)return;let[r,i]=Array.from(t.values()),a=Math.hypot(r.x-i.x,r.y-i.y),o=G.current;G.current=a,o!=null&&o>0&&a>0&&J(a/o,(r.x+i.x)/2,(r.y+i.y)/2)},$=I>1;return(0,g.jsx)(s,{title:i,fullscreen:T,onClose:_,icon:(0,g.jsx)(r,{className:`w-5 h-5 text-bambu-green flex-shrink-0`}),actions:A?(0,g.jsxs)(g.Fragment,{children:[(0,g.jsx)(`button`,{onClick:()=>J(1/y),className:l,"aria-label":v(`fileManager.preview.zoomOut`),children:(0,g.jsx)(u,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:()=>J(y),className:l,"aria-label":v(`fileManager.preview.zoomIn`),children:(0,g.jsx)(n,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:Y,className:l,"aria-label":v(`fileManager.preview.resetZoom`),children:(0,g.jsx)(o,{className:`w-4 h-4`})})]}):void 0,children:(0,g.jsx)(`div`,{ref:O,"data-testid":`image-preview-content`,className:`relative flex-1 min-h-0 overflow-hidden bg-bambu-dark flex items-center justify-center touch-none ${E?``:`rounded-b-lg`} ${$?B?`cursor-grabbing`:`cursor-grab`:``}`,onDoubleClick:D,onPointerDown:X,onPointerMove:Q,onPointerUp:Z,onPointerCancel:Z,onPointerLeave:Z,children:P?(0,g.jsx)(`p`,{className:`text-bambu-gray text-center p-6`,children:P}):(0,g.jsxs)(g.Fragment,{children:[A&&(0,g.jsx)(`img`,{ref:k,src:A,alt:i,draggable:!1,onLoad:()=>N(!0),onError:()=>F(v(`fileManager.preview.error`)),style:{transform:`translate(${R.x}px, ${R.y}px) scale(${I})`},className:`max-w-full max-h-full object-contain select-none`}),!M&&(0,g.jsx)(`div`,{className:`absolute inset-0 flex items-center justify-center pointer-events-none`,children:(0,g.jsx)(f,{className:`w-8 h-8 text-bambu-green animate-spin`})})]})})})}export{T as ImagePreviewModal};
|
||||
import{i as e}from"./chunk-aKtaBQYM.js";import{a as t,c as n,d as r,g as i,i as a,l as o,n as s,o as c,r as l,s as u,t as d,u as f,v as p,y as m}from"./index-DCfEMLCI.js";var h=e(m(),1),g=i(),_=.4,v=8,y=1.25,b=30,x=Math.log(y)/b,S=e=>Math.min(v,Math.max(_,e)),C={x:0,y:0};function w(e){return e.deltaMode===1?e.deltaY*16:e.deltaMode===2?e.deltaY*100:e.deltaY}function T({libraryFileId:e,filename:i,fileSize:m,onClose:_}){let{t:v}=p(),T=d(),{isFullscreen:E,toggleFullscreen:D}=T,O=(0,h.useRef)(null),k=(0,h.useRef)(null),[A,j]=(0,h.useState)(null),[M,N]=(0,h.useState)(!1),[P,F]=(0,h.useState)(null),[I,L]=(0,h.useState)(1),[R,z]=(0,h.useState)(C),[B,V]=(0,h.useState)(!1),H=(0,h.useRef)(1),U=(0,h.useRef)(C),W=(0,h.useRef)(new Map),G=(0,h.useRef)(null),K=(0,h.useCallback)((e,t)=>{let n=O.current,r=k.current;if(!n||!r)return e;let i=Math.max(0,(r.offsetWidth*t-n.clientWidth)/2),a=Math.max(0,(r.offsetHeight*t-n.clientHeight)/2);return{x:Math.min(i,Math.max(-i,e.x)),y:Math.min(a,Math.max(-a,e.y))}},[]),q=(0,h.useCallback)((e,t)=>{H.current=e,U.current=t,L(e),z(t)},[]),J=(0,h.useCallback)((e,t,n)=>{let r=H.current,i=S(r*e);if(i===r)return;let a=O.current,o=U.current,s={x:o.x*(i/r),y:o.y*(i/r)};if(a){let e=a.getBoundingClientRect(),c=(t??e.left+e.width/2)-(e.left+e.width/2),l=(n??e.top+e.height/2)-(e.top+e.height/2),u=i/r;s={x:c-(c-o.x)*u,y:l-(l-o.y)*u}}q(i,K(s,i))},[K,q]),Y=(0,h.useCallback)(()=>q(1,C),[q]);(0,h.useEffect)(()=>{let n=!1,r=null;if(j(null),N(!1),F(null),q(1,C),m>52428800){F(v(`fileManager.preview.tooLarge`,{size:a(m)}));return}let i={},o=c();return o&&(i.Authorization=`Bearer ${o}`),(async()=>{let a=await fetch(t.getLibraryFileDownloadUrl(e),{headers:i});if(!a.ok)throw Error(`HTTP ${a.status}`);let o=await a.blob();n||(r=URL.createObjectURL(o),j(r))})().catch(e=>{console.error(`[image-preview] load failed`,e),n||F(v(`fileManager.preview.error`))}),()=>{n=!0,r&&URL.revokeObjectURL(r)}},[e,m,v,q]),(0,h.useEffect)(()=>{let e=e=>{e.ctrlKey||e.metaKey||e.altKey||(e.key===`+`||e.key===`=`?(e.preventDefault(),J(y)):e.key===`-`?(e.preventDefault(),J(1/y)):e.key===`0`&&(e.preventDefault(),Y()))};return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[Y,J]),(0,h.useEffect)(()=>{let e=O.current;if(!e)return;let t=e=>{e.preventDefault();let t=Math.max(-30,Math.min(b,w(e)));J(Math.exp(-t*x),e.clientX,e.clientY)};return e.addEventListener(`wheel`,t,{passive:!1}),()=>e.removeEventListener(`wheel`,t)},[J,A]);let X=e=>{W.current.set(e.pointerId,{x:e.clientX,y:e.clientY}),G.current=null,W.current.size===1&&H.current>1&&(e.currentTarget.setPointerCapture(e.pointerId),V(!0))},Z=e=>{W.current.delete(e.pointerId),G.current=null,W.current.size===0&&V(!1)},Q=e=>{let t=W.current,n=t.get(e.pointerId);if(!n)return;if(e.pointerType===`mouse`&&e.buttons===0){Z(e);return}if(t.set(e.pointerId,{x:e.clientX,y:e.clientY}),t.size===1){if(H.current<=1)return;let t={x:U.current.x+(e.clientX-n.x),y:U.current.y+(e.clientY-n.y)};q(H.current,K(t,H.current));return}if(t.size!==2)return;let[r,i]=Array.from(t.values()),a=Math.hypot(r.x-i.x,r.y-i.y),o=G.current;G.current=a,o!=null&&o>0&&a>0&&J(a/o,(r.x+i.x)/2,(r.y+i.y)/2)},$=I>1;return(0,g.jsx)(s,{title:i,fullscreen:T,onClose:_,icon:(0,g.jsx)(r,{className:`w-5 h-5 text-bambu-green flex-shrink-0`}),actions:A?(0,g.jsxs)(g.Fragment,{children:[(0,g.jsx)(`button`,{onClick:()=>J(1/y),className:l,"aria-label":v(`fileManager.preview.zoomOut`),children:(0,g.jsx)(u,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:()=>J(y),className:l,"aria-label":v(`fileManager.preview.zoomIn`),children:(0,g.jsx)(n,{className:`w-4 h-4`})}),(0,g.jsx)(`button`,{onClick:Y,className:l,"aria-label":v(`fileManager.preview.resetZoom`),children:(0,g.jsx)(o,{className:`w-4 h-4`})})]}):void 0,children:(0,g.jsx)(`div`,{ref:O,"data-testid":`image-preview-content`,className:`relative flex-1 min-h-0 overflow-hidden bg-bambu-dark flex items-center justify-center touch-none ${E?``:`rounded-b-lg`} ${$?B?`cursor-grabbing`:`cursor-grab`:``}`,onDoubleClick:D,onPointerDown:X,onPointerMove:Q,onPointerUp:Z,onPointerCancel:Z,onPointerLeave:Z,children:P?(0,g.jsx)(`p`,{className:`text-bambu-gray text-center p-6`,children:P}):(0,g.jsxs)(g.Fragment,{children:[A&&(0,g.jsx)(`img`,{ref:k,src:A,alt:i,draggable:!1,onLoad:()=>N(!0),onError:()=>F(v(`fileManager.preview.error`)),style:{transform:`translate(${R.x}px, ${R.y}px) scale(${I})`},className:`max-w-full max-h-full object-contain select-none`}),!M&&(0,g.jsx)(`div`,{className:`absolute inset-0 flex items-center justify-center pointer-events-none`,children:(0,g.jsx)(f,{className:`w-8 h-8 text-bambu-green animate-spin`})})]})})})}export{T as ImagePreviewModal};
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-7qfiHY5t.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DCfEMLCI.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CyW_3l56.css">
|
||||
</head>
|
||||
|
||||
Reference in New Issue
Block a user