Merge branch 'dev' into feature/oidc-env-config

This commit is contained in:
MartinNYHC
2026-08-01 11:22:58 +02:00
committed by GitHub
35 changed files with 2197 additions and 539 deletions
+7
View File
@@ -5,6 +5,13 @@ All notable changes to Bambuddy will be documented in this file.
## [1.2.6b1] - Unreleased
### Fixed
- **The Settings page no longer reverts settings changed from anywhere else (#2716, reporter @jmoore-skild)** — While the Settings page was open it held its own copy of every setting and only ever took one from the server, on first load. A background effect then compared that copy against the server's and saved the whole thing back on any difference — with no way to tell "the user edited this field" from "this field changed on the server". So anything written while the page sat open was silently undone: a change made in a second tab, another user's change on a shared install, a restore from a backup. It needed no click to trigger. The page's data goes stale after a minute and refreshes when the window regains focus, and around thirty other places in the app read the same settings, so a refresh from any of them was enough — after which the page wrote its page-load copy back over all 77 settings it manages, and showed **Settings saved** while doing it. The page now keeps track of the last server state it reconciled with. A field still matching that state has not been touched, so a newer value from the server is adopted and displayed; a field the user has edited keeps their value and is saved over the top, so the newer of the two writes wins either way. Typing into a text field while a refresh lands is still safe, which is what the old behaviour was protecting. Covered by frontend tests.
- **A rejected K-profile write is now reported as rejected (#2718, reporter @jmoore-skild)** — Saving a K-profile was fire-and-forget: Bambuddy published the command and reported success the moment the bytes left the process. The printer does answer, and the answer was received, matched, and thrown away at debug level — so a write the printer refused for a real reason still told you it was saved. The complication was that the answer itself was wrong: on single-nozzle printers it came back `result: "fail", reason: "invalid tray_id"` on writes that demonstrably applied, which made gating on it look impossible. Measuring against an X1C and an H2D found the cause — the `tray_id: -1` Bambuddy itself put in the payload. The X1C's firmware validates that field and rejects the value while applying the write anyway; the H2D ignores it. Sending `0`, as BambuStudio does, makes the acknowledgement honest, and the printer echoes back the sequence number we sent, so it can be matched to the write that caused it. Saving or deleting a profile now waits for that answer and surfaces a genuine rejection as an error instead of a success toast. A printer that stays silent is still treated as success — no answer is not evidence of refusal. The acknowledgement is also logged at INFO now, so it appears in a support bundle. Covered by backend tests.
- **The K-profile flow type is a real choice again** — On most printers the calibration table comes back with no nozzle identity at all, and Bambuddy had started showing "Not reported by printer" in the Flow Type field as a result. That is not a value you can save, and it isn't what the slicer does: BambuStudio treats a missing nozzle identity as **Standard** and leaves the choice editable. Bambuddy now does the same. The field is hidden only on models sold with a single nozzle variant — the A1, A1 Mini and A2L — using the same rule the slicer applies. This is not the single-versus-dual-nozzle split: the P1P, P1S, P2S, X1, X1 Carbon, X1E and H2S are all single-nozzle and all offer both flows. Editing a profile also no longer strips the nozzle identity from what it writes back.
- **Dialogs no longer act after they have closed** — The AMS slot configuration and K-Profile dialogs hold their success state briefly and then close themselves, between 1.5 and 4 seconds after the command is sent so the printer has time to process it. That timer ran whether or not the dialog was still open, so dismissing it — or the printer card refreshing underneath it — within that window left a pending close that fired later, dismissing whatever dialog happened to be open by then. The deferred close is now cancelled when the dialog goes away. Covered by frontend tests.
- **A printer with no K-profiles can now be given its first one (#2719, reporter @jmoore-skild)** — **Add K-Profile** built its Filament dropdown out of the profiles already on the printer, so on a printer with none the field was empty, required, and impossible to satisfy — the modal even said so, telling you to go and create the profile in Bambu Studio instead. The filament picker is now populated the way every other one in Bambuddy is, in the same order: **Imported** presets first, then **Orca Cloud**, then **Bambu Cloud**, then Bambuddy's built-in Bambu filament table. That last tier is compiled in, so the list is never empty — a brand-new printer with no cloud account and nothing imported still gets you a profile. The per-printer-model copies a cloud account carries ("Bambu PLA Basic" once for the X1C, once for the P1S, once for the A1) are collapsed into a single row, and the built-in table — a static copy of the same Bambu catalogue — no longer echoes back filaments the groups above already list. Your imported and Orca Cloud libraries are both shown in full even where they overlap by name, because they are usually the same profiles reached two ways and each group is worth seeing under its own heading. The picker is a searchable list with the source heading shown as a real, legible group header — a native dropdown can't do that, since browsers render the group label of a `<select>` in small grey italics and ignore styling on it. Imported and Orca Cloud presets carry no Bambu filament ID, and the printer indexes its calibration table by one, so those are filed under the closest generic for their material — the same rule the AMS slot configuration already uses, so a profile created here matches the slot configured there. A filament whose material Bambuddy can't place is refused with an explanation rather than written under a wrong ID. Also drops a second, redundant K-profile fetch the old dropdown needed: it ran concurrently with the main one whenever a non-0.4mm nozzle was selected, which is exactly the case that made K-profile requests time out. Translated in all locales; wiki updated. Covered by frontend tests.
- **K-profiles no longer all report 0.4mm / High Flow (#1748, reporters @Liquidmasl and @jmoore-skild)** — On any printer running a nozzle other than 0.4mm, every K-profile showed up as `0.4` with a flow type nobody had set, and the same profile disagreed with itself: the list said **S**, the edit dialog said **High Flow**. The printer reports the nozzle diameter once, on the response envelope; the individual profile entries carry no diameter and no nozzle id at all. Bambuddy read the diameter *per entry* and fell back to a hardcoded `0.4` when it wasn't there — which was always. The envelope value is now used, so profiles report the nozzle they were actually calibrated for. This was not only cosmetic. Editing a profile is delete-and-re-add on single-nozzle printers, and the dialog rebuilt the nozzle fields from its own (greyed-out) dropdowns, so saving an untouched 0.6mm profile rewrote it on the printer as 0.4mm High Flow. Deleting one aimed the command at the wrong nozzle for the same reason. Both now pass through exactly what the printer reported. Assigning a spool's stored calibration to an AMS slot was affected too: that lookup matches on nozzle diameter, so on a 0.6 or 0.8 nozzle it never found the printer-side entry and the cali_idx silently failed to stick — the "can't auto-map a K-profile" half of the report. Where the printer sends no nozzle id, Bambuddy now says so instead of picking one: the list shows the diameter alone, the dialog shows **Not reported by printer**, and the High Flow / Standard filter is hidden rather than offered as a control that can only ever empty the list. Also fixes the flow type filter selecting the opposite label when naming a new profile. Translated in all locales. Covered by backend tests.
- **K-profile requests no longer time out when two run at once (#1748)** — Fetching profiles for one nozzle size while another fetch was open made the first one time out, with `Failed to get K-profiles after 3 attempts` in the log, even though the printer had answered both correctly. Responses were matched to requests by nozzle diameter held in a single shared slot, so the second request overwrote the first's expectation and the first's valid answer was discarded as a mismatch. Requests are now correlated by the sequence id Bambuddy already sends and tracked one entry per request, with the old nozzle match kept as a fallback for firmware that doesn't echo the id back. An unsolicited broadcast arriving mid-fetch also no longer replaces the profile list the fetch is waiting on. Covered by backend tests.
- **Git backup now actually writes cloud profiles (#2717, reporter @jmoore-skild)** — Enabling **Cloud Profiles** for a Git backup produced nothing. The collector looked for a `setting` list in the Bambu Cloud response, which is keyed by preset type instead, so the loop never ran once — and it asked for the credential store used when authentication is *disabled*, so on any install with authentication on it found no account to collect from in the first place. Neither failure was visible: `backup_metadata.json` still recorded `cloud_profiles: true`, and the log line read `Collected cloud profiles: 0 filament, 0 printer, 0 process`, which looks like a successful backup of an empty account. Cloud profiles are now collected from **every connected account across both Bambu Cloud and Orca Cloud**, one directory per cloud per account, keyed by user ID so no email address is written into a backup repository. Bambu presets are stored with the payload needed to recreate them rather than just their names, and Bambu's bundled public catalogue is skipped — it is identical for everyone, re-downloadable, and would rewrite the repository on every run. The metadata now records what was actually collected, per cloud and per account, and a run that collects nothing while the category is enabled says so as a warning instead of an INFO line that reads like success. The Cloud Profiles checkbox no longer keys off your own Bambu sign-in — it enables when *any* account is connected and shows how many are in scope, which matters on a multi-user install where the presets being backed up are other people's. A backup also no longer disconnects an Orca Cloud account whose session it can't refresh: Orca reports every rejection with one composite reason, so a genuine revocation is indistinguishable from a lost token-rotation race, and an unattended job should not be the thing that guesses. The account is skipped with a warning, and the dead credentials are cleared the next time you open Orca Cloud Profiles — where you can pair again on the spot. Translated in all locales; wiki updated. Covered by backend tests.
### Added
+11 -14
View File
@@ -223,21 +223,18 @@ The frontend uses [react-i18next](https://react.i18next.com/) for all user-facin
### Locale Files
Translations live in `frontend/src/i18n/locales/`:
Translations live in `frontend/src/i18n/locales/`. `en.ts` is the reference locale; every other `*.ts` file in that directory is checked against it. The parity check discovers the directory at runtime, so a new locale is picked up automatically — this file never needs updating when one is added.
| File | Language |
|------|----------|
| `en.ts` | English (primary) |
| `de.ts` | German |
| `fr.ts` | French |
| `ja.ts` | Japanese |
| `pt-BR.ts` | Brazilian Portuguese |
[...]
check for possibly more files!!!
To see the current set of locales and check your work:
```bash
cd frontend
npm run check:i18n
```
### Adding New Strings
1. Add the key to the appropriate section in **all three** locale files
1. Add the key to the appropriate section in **every** locale file
2. Use the `useTranslation` hook in your component:
```tsx
@@ -253,9 +250,9 @@ function MyComponent() {
### Important Notes
- All three locale files must use the **same key structure** — same nesting, same key paths
- Always add keys to all three locales to maintain parity
- Run frontend tests after changes — locale parity is validated
- Every locale file must use the **same key structure** — same nesting, same key paths
- Always add keys to **every** locale to maintain parity, with real translations rather than English placeholders — the check flags leaves that are identical to `en`
- Run `npm run test:run` before pushing — it chains the parity check, which CI runs too. Plain `npm test` is vitest in watch mode and skips it
- If you find structural inconsistencies between locales, fix them — different key paths cause silent fallback to English
## Authentication & Permissions
+18
View File
@@ -148,6 +148,9 @@ async def set_kprofile(
)
if not delete_success:
raise HTTPException(500, "Failed to delete existing K-profile for edit")
ok, detail = await client.await_cali_ack(delete_success)
if not ok:
raise HTTPException(500, f"Printer rejected the K-profile edit: {detail}")
# Wait for printer to process the delete before adding
await asyncio.sleep(0.5)
@@ -179,6 +182,13 @@ async def set_kprofile(
if not success:
raise HTTPException(500, "Failed to send K-profile command")
# The printer answers extrusion_cali_set with result/reason, echoing our
# sequence_id. Until #2718 that answer was logged at DEBUG and discarded,
# so a rejected write was reported to the user as saved.
ok, detail = await client.await_cali_ack(success)
if not ok:
raise HTTPException(500, f"Printer rejected the K-profile: {detail}")
message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
return {"success": True, "message": message}
@@ -239,6 +249,10 @@ async def set_kprofiles_batch(
if not success:
raise HTTPException(500, "Failed to send K-profiles batch command")
ok, detail = await client.await_cali_ack(success)
if not ok:
raise HTTPException(500, f"Printer rejected the K-profiles: {detail}")
return {"success": True, "message": f"Added {len(profiles)} K-profiles"}
@@ -283,6 +297,10 @@ async def delete_kprofile(
if not success:
raise HTTPException(500, "Failed to send K-profile delete command")
ok, detail = await client.await_cali_ack(success)
if not ok:
raise HTTPException(500, f"Printer rejected the delete: {detail}")
# Wait for printer to process the delete before frontend refetches
await asyncio.sleep(0.5)
+8
View File
@@ -2,6 +2,8 @@ from datetime import datetime
from pydantic import BaseModel, Field, field_validator
from backend.app.utils.printer_models import supports_nozzle_flow_type
class PrinterBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
@@ -81,6 +83,11 @@ class PrinterResponse(PrinterBase):
id: int
is_active: bool
nozzle_count: int = 1 # 1 or 2, auto-detected from MQTT
# Whether the model is sold with both Standard and High Flow nozzles, so a
# K-profile's flow type is a real choice rather than a meaningless field.
# Derived from the model, not from nozzle_count — see
# printer_models.supports_nozzle_flow_type.
supports_nozzle_flow_type: bool = True
print_hours_offset: float = 0.0
external_camera_url: str | None = None
external_camera_type: str | None = None
@@ -113,6 +120,7 @@ class PrinterResponse(PrinterBase):
"camera_rotation": printer.camera_rotation,
"is_active": printer.is_active,
"nozzle_count": printer.nozzle_count,
"supports_nozzle_flow_type": supports_nozzle_flow_type(printer.model),
"print_hours_offset": printer.print_hours_offset,
"plate_detection_enabled": printer.plate_detection_enabled,
"created_at": printer.created_at,
+227 -115
View File
@@ -812,10 +812,17 @@ class BambuMQTTClient:
# so that missing-serial / missing-firmware warnings fire only once per connection.
self._ams_version_warned: set[tuple[int | str, str]] = set()
# K-profile command tracking
# K-profile command tracking. One entry per in-flight extrusion_cali_get,
# keyed by the sequence_id we sent, so two concurrent requests for
# different nozzle sizes can't steal each other's response (#1748).
# Value: {"nozzle": str, "event": asyncio.Event, "profiles": list | None}.
self._sequence_id: int = 0
self._pending_kprofile_response: asyncio.Event | None = None
self._kprofile_response_data: list | None = None
self._pending_kprofile_requests: dict[str, dict] = {}
# Acks for K-profile *writes* (extrusion_cali_set / extrusion_cali_del),
# keyed by the sequence_id we sent. The printer echoes it back, measured
# on both an X1C and an H2D (#2718). Filled by the MQTT thread, drained
# by await_cali_ack.
self._pending_cali_acks: dict[str, dict | None] = {}
# Xcam hold timers - OrcaSlicer pattern: ignore incoming data for 3 seconds after command
# Key: module_name, Value: timestamp when command was sent
@@ -1616,7 +1623,24 @@ class BambuMQTTClient:
if "command" in print_data:
cmd = print_data.get("command")
logger.debug("[%s] Received command response: %s", self.serial_number, cmd)
if cmd in ("extrusion_cali_sel", "extrusion_cali_set", "extrusion_cali_del", "ams_filament_setting"):
if cmd in ("extrusion_cali_set", "extrusion_cali_del"):
# INFO, not debug: this is the printer's verdict on a write
# the user just made, and it was invisible in support
# bundles for as long as it sat at DEBUG (#2718). Same
# reasoning as ams_filament_drying below.
logger.info(
"[%s] %s response: result=%s reason=%s seq=%s",
self.serial_number,
cmd,
print_data.get("result"),
print_data.get("reason", ""),
print_data.get("sequence_id"),
)
logger.debug("[%s] %s full response: %s", self.serial_number, cmd, print_data)
ack_seq = str(print_data.get("sequence_id", ""))
if ack_seq in self._pending_cali_acks:
self._pending_cali_acks[ack_seq] = print_data
elif cmd in ("extrusion_cali_sel", "ams_filament_setting"):
logger.debug("[%s] %s response: %s", self.serial_number, cmd, print_data)
# AMS drying responses are rare (user-initiated only) and the
# full payload — including `result` and any `reason` code —
@@ -5412,98 +5436,120 @@ class BambuMQTTClient:
self._drying_targets.pop(ams_id, None)
return True
@staticmethod
def _parse_kprofile_entries(filaments: list, response_nozzle: str | None, log_errors: bool) -> list[KProfile]:
"""Build KProfile objects from an ``extrusion_cali_get`` filaments array.
The printer reports ``nozzle_diameter`` **only on the response
envelope** — the per-filament entries carry just setting_id,
filament_id, name, k_value, n_coef and cali_idx. Defaulting the
per-entry lookup to "0.4" therefore stamped every profile 0.4mm on
single-nozzle printers regardless of the installed nozzle (#1748),
which broke the K-Profiles display and, worse, the cali_idx cascade
in the inventory/Spoolman assign paths that matches on
nozzle_diameter. Fall back to the envelope value instead, and only
to "0.4" when the envelope has none either.
``or`` rather than a dict default on purpose: it also covers an entry
that carries the key with an empty value, and stops ``str()`` turning
a missing envelope value into the literal "None".
"""
profiles: list[KProfile] = []
for i, f in enumerate(filaments):
if not isinstance(f, dict):
continue
try:
profiles.append(
KProfile(
# cali_idx is the actual slot/calibration index from the printer
slot_id=f.get("cali_idx", i),
extruder_id=int(f.get("extruder_id", 0)),
nozzle_id=str(f.get("nozzle_id", "")),
nozzle_diameter=str(f.get("nozzle_diameter") or response_nozzle or "0.4"),
filament_id=str(f.get("filament_id", "")),
name=str(f.get("name", "")),
k_value=str(f.get("k_value", "0.000000")),
n_coef=str(f.get("n_coef", "0.000000")),
ams_id=int(f.get("ams_id", 0)),
tray_id=int(f.get("tray_id", -1)),
setting_id=f.get("setting_id"),
)
)
except (ValueError, TypeError) as e:
# Skip malformed entries; the remaining profiles stay usable.
# Unsolicited broadcasts arrive constantly, so only a response
# someone is actually waiting on is worth a warning.
if log_errors:
logger.warning("Failed to parse K-profile: %s", e)
else:
logger.debug("Failed to parse K-profile from broadcast: %s", e)
return profiles
def _handle_kprofile_response(self, data: dict):
"""Handle K-profile response from printer."""
response_nozzle = data.get("nozzle_diameter")
response_seq_id = data.get("sequence_id", "?")
response_seq_id = str(data.get("sequence_id", ""))
filaments = data.get("filaments", [])
expected_nozzle = getattr(self, "_expected_kprofile_nozzle", None)
has_pending_request = self._pending_kprofile_response is not None
# Log all incoming responses when we have a pending request (for debugging)
if has_pending_request:
# Snapshot the map: the asyncio thread adds and removes entries while
# this MQTT callback thread walks it.
pending = dict(self._pending_kprofile_requests)
request = pending.get(response_seq_id)
if request is None and pending:
# Firmware that doesn't echo our sequence_id still has to be
# served, so fall back to the pre-#1748 rule of matching on the
# nozzle size. Only requests still waiting are eligible, and the
# sequence_id lookup above has already claimed any response that
# identifies itself, so this can no longer hand request A's
# answer to request B when both are in flight.
request = next(
(r for r in pending.values() if r["nozzle"] == response_nozzle and r["profiles"] is None),
None,
)
if pending:
logger.info(
f"[{self.serial_number}] K-profile response: nozzle={response_nozzle}, "
f"seq_id={response_seq_id}, {len(filaments)} profiles, expected={expected_nozzle}"
"[%s] K-profile response: nozzle=%s, seq_id=%s, %d profiles, matched=%s",
self.serial_number,
response_nozzle,
response_seq_id or "?",
len(filaments),
request is not None,
)
# If we have a pending request, only accept responses with matching nozzle_diameter
# The printer broadcasts 0.4mm profiles constantly - we need to wait for the actual response
if has_pending_request and expected_nozzle and response_nozzle != expected_nozzle:
# Ignore this broadcast, keep waiting for matching response
if request is None and pending:
# A request is outstanding and this isn't its answer. The printer
# broadcasts extrusion_cali_get unsolicited, so letting this
# through would replace state.kprofiles with another nozzle's
# profiles while the caller is still waiting.
logger.debug(
f"[{self.serial_number}] Ignoring broadcast: got nozzle={response_nozzle}, waiting for {expected_nozzle}"
"[%s] Ignoring unmatched K-profile response: nozzle=%s, seq_id=%s",
self.serial_number,
response_nozzle,
response_seq_id or "?",
)
return
# If no pending request, this is just a broadcast - update state silently and return early
if not has_pending_request:
# Still parse profiles to keep state updated, but don't log
profiles = []
for f in filaments:
if isinstance(f, dict):
try:
cali_idx = f.get("cali_idx", 0)
profiles.append(
KProfile(
slot_id=cali_idx,
extruder_id=int(f.get("extruder_id", 0)),
nozzle_id=str(f.get("nozzle_id", "")),
nozzle_diameter=str(f.get("nozzle_diameter", "0.4")),
filament_id=str(f.get("filament_id", "")),
name=str(f.get("name", "")),
k_value=str(f.get("k_value", "0.000000")),
n_coef=str(f.get("n_coef", "0.000000")),
ams_id=int(f.get("ams_id", 0)),
tray_id=int(f.get("tray_id", -1)),
setting_id=f.get("setting_id"),
)
)
except (ValueError, TypeError):
pass # Skip malformed K-profile entries; remaining profiles still usable
self.state.kprofiles = profiles
profiles = self._parse_kprofile_entries(filaments, response_nozzle, log_errors=request is not None)
self.state.kprofiles = profiles
if request is None:
# Unsolicited broadcast with nothing in flight: state is refreshed,
# nobody to wake.
return
profiles = []
logger.info("[%s] Got %s K-profiles for nozzle=%s", self.serial_number, len(profiles), response_nozzle)
request["profiles"] = profiles
for i, f in enumerate(filaments):
if isinstance(f, dict):
try:
# cali_idx is the actual slot/calibration index from the printer
cali_idx = f.get("cali_idx", i)
profiles.append(
KProfile(
slot_id=cali_idx,
extruder_id=int(f.get("extruder_id", 0)),
nozzle_id=str(f.get("nozzle_id", "")),
nozzle_diameter=str(f.get("nozzle_diameter", "0.4")),
filament_id=str(f.get("filament_id", "")),
name=str(f.get("name", "")),
k_value=str(f.get("k_value", "0.000000")),
n_coef=str(f.get("n_coef", "0.000000")),
ams_id=int(f.get("ams_id", 0)),
tray_id=int(f.get("tray_id", -1)),
setting_id=f.get("setting_id"),
)
)
except (ValueError, TypeError) as e:
logger.warning("Failed to parse K-profile: %s", e)
self.state.kprofiles = profiles
self._kprofile_response_data = profiles
# Signal that we received the response (only if we were waiting for one)
# Use thread-safe method since MQTT callbacks run in a different thread
# Capture in local var to avoid TOCTOU race: asyncio thread can clear
# self._pending_kprofile_response between the check and the .set() call
event = self._pending_kprofile_response
if event:
logger.info("[%s] Got %s K-profiles for nozzle=%s", self.serial_number, len(profiles), response_nozzle)
if self._loop and self._loop.is_running():
self._loop.call_soon_threadsafe(event.set)
else:
# Fallback for when loop is not available
event.set()
# Signal the waiter. Use the thread-safe path since MQTT callbacks run
# in a different thread than the event loop.
event = request["event"]
if self._loop and self._loop.is_running():
self._loop.call_soon_threadsafe(event.set)
else:
# Fallback for when loop is not available
event.set()
async def get_kprofiles(
self, nozzle_diameter: str = "0.4", timeout: float = 5.0, max_retries: int = 3
@@ -5533,11 +5579,13 @@ class BambuMQTTClient:
return []
for attempt in range(max_retries):
# Set up response event for this attempt
# Register this attempt under its own sequence_id so a concurrent
# request for a different nozzle size can't consume its response
# (#1748) — the pending map is keyed by exactly the id we send.
self._sequence_id += 1
self._pending_kprofile_response = asyncio.Event()
self._kprofile_response_data = None
self._expected_kprofile_nozzle = nozzle_diameter # Track which nozzle response we expect
seq_id = str(self._sequence_id)
request: dict = {"nozzle": nozzle_diameter, "event": asyncio.Event(), "profiles": None}
self._pending_kprofile_requests[seq_id] = request
# Send the command with nozzle_diameter filter
command = {
@@ -5545,20 +5593,20 @@ class BambuMQTTClient:
"command": "extrusion_cali_get",
"filament_id": "",
"nozzle_diameter": nozzle_diameter,
"sequence_id": str(self._sequence_id),
"sequence_id": seq_id,
}
}
logger.info(
f"[{self.serial_number}] Requesting K-profiles for nozzle_diameter={nozzle_diameter} (attempt {attempt + 1}/{max_retries})"
f"[{self.serial_number}] Requesting K-profiles for nozzle_diameter={nozzle_diameter} (attempt {attempt + 1}/{max_retries}, seq_id={seq_id})"
)
logger.debug("[%s] K-profile request JSON: %s", self.serial_number, json.dumps(command))
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
# Wait for response (response handler already filters by nozzle_diameter)
# Wait for the response (the handler matches it back to this entry)
try:
await asyncio.wait_for(self._pending_kprofile_response.wait(), timeout=timeout)
profiles = self._kprofile_response_data or []
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
await asyncio.wait_for(request["event"].wait(), timeout=timeout)
profiles = request["profiles"] or []
logger.info(
f"[{self.serial_number}] Got {len(profiles)} K-profiles for nozzle={nozzle_diameter} on attempt {attempt + 1}"
)
@@ -5571,12 +5619,56 @@ class BambuMQTTClient:
# Brief delay before retry
await asyncio.sleep(0.5)
finally:
self._pending_kprofile_response = None
self._expected_kprofile_nozzle = None
self._pending_kprofile_requests.pop(seq_id, None)
logger.error("[%s] Failed to get K-profiles after %s attempts", self.serial_number, max_retries)
return []
def _publish_cali_write(self, command: dict, seq_id: str) -> bool:
"""Publish a K-profile write and arm its ack slot.
Registration happens before the publish because the printer answers in
well under a second — measured at 70-150ms — which is comfortably
before an async caller gets back to awaiting.
"""
self._pending_cali_acks[seq_id] = None
try:
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
except Exception:
self._pending_cali_acks.pop(seq_id, None)
raise
return True
async def await_cali_ack(self, seq_id: str, timeout: float = 6.0) -> tuple[bool, str]:
"""Wait for the printer's verdict on a K-profile write.
Returns ``(ok, detail)``. ``ok`` is False only when the printer
explicitly said ``result: "fail"`` — a timeout returns True with a
detail string, because "no answer" is not evidence of rejection and
older firmware may not answer at all. Callers that need certainty read
the calibration table back.
Polled rather than event-driven on purpose: the ack is filled in by the
MQTT callback thread, and polling a dict costs one lookup every 50ms
for at most a few hundred milliseconds, against the cross-thread
event plumbing it would otherwise take.
"""
deadline = time.monotonic() + timeout
try:
while time.monotonic() < deadline:
ack = self._pending_cali_acks.get(seq_id)
if ack is not None:
result = str(ack.get("result", "")).lower()
reason = str(ack.get("reason", "") or "")
if result == "fail":
return (False, reason or "printer reported failure")
return (True, reason)
await asyncio.sleep(0.05)
finally:
self._pending_cali_acks.pop(seq_id, None)
logger.warning("[%s] No ack for K-profile write seq=%s within %.1fs", self.serial_number, seq_id, timeout)
return (True, "no acknowledgement from printer")
def set_kprofile(
self,
filament_id: str,
@@ -5588,7 +5680,7 @@ class BambuMQTTClient:
setting_id: str | None = None,
slot_id: int = 0,
cali_idx: int | None = None,
) -> bool:
) -> str | None:
"""Set/update a K-profile on the printer.
Args:
@@ -5603,13 +5695,16 @@ class BambuMQTTClient:
cali_idx: For edits, the existing slot being edited (enables in-place edit)
Returns:
True if command was sent, False otherwise
The sequence_id the command was sent under, so the caller can
await the printer's verdict via await_cali_ack. None if the
command could not be sent.
"""
if not self._client or not self.state.connected:
logger.warning("[%s] Cannot set K-profile: not connected", self.serial_number)
return False
return None
self._sequence_id += 1
seq_id = str(self._sequence_id)
# Build the filament entry - printer uses cali_idx for profile identification
# For new profiles (slot_id=0), use cali_idx=-1 to tell printer to create new slot
@@ -5637,7 +5732,13 @@ class BambuMQTTClient:
"nozzle_diameter": nozzle_diameter,
"nozzle_id": nozzle_id,
"setting_id": setting_id if setting_id else "",
"tray_id": -1,
# 0, not -1. Single-nozzle firmware validates this field and
# answers `result: "fail", reason: "invalid tray_id"` to -1 — while
# applying the write anyway, so the rejection looked like noise.
# Measured on an X1C: flipping only this value turns the ack into
# `success` (#2718). BambuStudio always sends a real tray_id and
# defaults it to 0 for a manually entered profile.
"tray_id": 0,
}
command = {
@@ -5645,7 +5746,7 @@ class BambuMQTTClient:
"command": "extrusion_cali_set",
"filaments": [filament_entry],
"nozzle_diameter": nozzle_diameter,
"sequence_id": str(self._sequence_id),
"sequence_id": seq_id,
}
}
@@ -5654,14 +5755,14 @@ class BambuMQTTClient:
f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={effective_cali_idx}, new={slot_id == 0})"
)
logger.debug("[%s] K-profile SET command: %s", self.serial_number, command_json)
self._client.publish(self.topic_publish, command_json, qos=1)
return True
self._publish_cali_write(command, seq_id)
return seq_id
def set_kprofiles_batch(
self,
profiles: list[dict],
nozzle_diameter: str = "0.4",
) -> bool:
) -> str | None:
"""Set multiple K-profiles in a single command (for dual-nozzle).
Args:
@@ -5670,15 +5771,17 @@ class BambuMQTTClient:
nozzle_diameter: Common nozzle diameter for all profiles
Returns:
True if command was sent, False otherwise
The sequence_id the command was sent under (see set_kprofile),
or None if it could not be sent.
"""
if not self._client or not self.state.connected:
logger.warning("[%s] Cannot set K-profiles batch: not connected", self.serial_number)
return False
return None
import random
self._sequence_id += 1
seq_id = str(self._sequence_id)
filament_entries = []
for p in profiles:
@@ -5706,7 +5809,9 @@ class BambuMQTTClient:
"nozzle_diameter": nozzle_diameter,
"nozzle_id": p.get("nozzle_id", f"HS00-{nozzle_diameter}"),
"setting_id": setting_id if setting_id else "",
"tray_id": -1,
# See set_kprofile: -1 is rejected as "invalid tray_id" by
# single-nozzle firmware even though the write lands (#2718).
"tray_id": 0,
}
)
@@ -5715,15 +5820,15 @@ class BambuMQTTClient:
"command": "extrusion_cali_set",
"filaments": filament_entries,
"nozzle_diameter": nozzle_diameter,
"sequence_id": str(self._sequence_id),
"sequence_id": seq_id,
}
}
command_json = json.dumps(command)
logger.info("[%s] Setting %s K-profiles in batch", self.serial_number, len(filament_entries))
logger.debug("[%s] K-profile SET batch command: %s", self.serial_number, command_json)
self._client.publish(self.topic_publish, command_json, qos=1)
return True
self._publish_cali_write(command, seq_id)
return seq_id
def delete_kprofile(
self,
@@ -5733,7 +5838,7 @@ class BambuMQTTClient:
nozzle_diameter: str = "0.4",
extruder_id: int = 0,
setting_id: str | None = None,
) -> bool:
) -> str | None:
"""Delete a K-profile from the printer.
Args:
@@ -5745,13 +5850,15 @@ class BambuMQTTClient:
setting_id: Unique setting identifier (for X1C series)
Returns:
True if command was sent, False otherwise
The sequence_id the command was sent under (see set_kprofile),
or None if it could not be sent.
"""
if not self._client or not self.state.connected:
logger.warning("[%s] Cannot delete K-profile: not connected", self.serial_number)
return False
return None
self._sequence_id += 1
seq_id = str(self._sequence_id)
# Dual-nozzle K-profile delete uses the extruder_id/nozzle_id format;
# single-nozzle printers (X1C/P1/A1/P2S/H2S) need the setting_id form.
@@ -5767,7 +5874,7 @@ class BambuMQTTClient:
command = {
"print": {
"command": "extrusion_cali_del",
"sequence_id": str(self._sequence_id),
"sequence_id": seq_id,
"extruder_id": extruder_id,
"nozzle_id": nozzle_id,
"filament_id": filament_id,
@@ -5781,7 +5888,7 @@ class BambuMQTTClient:
command = {
"print": {
"command": "extrusion_cali_del",
"sequence_id": str(self._sequence_id),
"sequence_id": seq_id,
"filament_id": filament_id,
"cali_idx": cali_idx,
"setting_id": setting_id if setting_id else "",
@@ -5796,9 +5903,9 @@ class BambuMQTTClient:
f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, setting_id={setting_id}, dual={is_dual_nozzle}"
)
logger.debug("[%s] K-profile DELETE command: %s", self.serial_number, command_json)
# Use QoS 1 for reliable delivery (at least once)
self._client.publish(self.topic_publish, command_json, qos=1)
return True
# QoS 1 for reliable delivery (at least once)
self._publish_cali_write(command, seq_id)
return seq_id
# =========================================================================
# Printer Control Commands
@@ -6641,6 +6748,11 @@ class BambuMQTTClient:
logger.warning("[%s] Cannot set K value: not connected", self.serial_number)
return False
# Was reusing the previous command's id — harmless while nothing
# correlated on it, but the printer echoes sequence_id back and the
# K-profile write path now matches acks by it (#2718).
self._sequence_id += 1
nozzle_id = f"HS00-{nozzle_diameter}"
# A2L AMS-Lite: a normalised global tray (24-27) must go out as the
+49
View File
@@ -116,6 +116,28 @@ LINEAR_RAIL_MODELS = frozenset(
)
# Models sold with a single nozzle flow variant, so a Standard / High Flow
# choice on a K-profile is meaningless there. Derived from the slicer's own
# rule (len(nozzle_volume) // len(nozzle_diameter) > 1 over the bundled Bambu
# machine presets), not from nozzle count — P1P/P1S/P2S/X1/X1C/X1E/H2S are
# single-nozzle and all carry two variants. Only the A-series has one.
SINGLE_NOZZLE_FLOW_MODELS = frozenset(
[
# Display names (uppercase, no spaces)
"A1",
"A1MINI",
"A2L",
# Internal codes
"N1", # A1 Mini
"N2S", # A1
"N9", # A2L
"A04", # A1 Mini (alternate)
"A11", # A1
"A12", # A1 Mini
]
)
# Models without any external storage (MicroSD / SD card slot).
# The A1 and A1 Mini ship with internal storage only — there is no
# firmware-side "Store sent files on external storage" toggle and no
@@ -290,6 +312,33 @@ def is_dual_nozzle_model(model: str | None) -> bool:
return normalized in DUAL_NOZZLE_MODELS
def supports_nozzle_flow_type(model: str | None) -> bool:
"""Return True if the model offers a Standard / High Flow nozzle choice.
A K-profile is filed under a ``nozzle_id`` of the form ``HS00-0.4``
(Standard) or ``HH00-0.4`` (High Flow), so the flow type is part of the
profile's identity on any printer where both exist — and meaningless noise
on one where only a single variant is sold.
The split is NOT the nozzle count: P1S, P2S, X1C and H2S are single-nozzle
and all offer both flows. BambuStudio/OrcaSlicer derive the same capability
from the machine preset — ``support_nozzle_volume()`` is
``len(nozzle_volume) // len(nozzle_diameter) > 1`` — and every bundled
Bambu profile evaluated against that formula puts only the A-series on the
"one variant" side (A1 and A1 Mini at 1, A2L at 1; everything from P1P
upward at 2 or more per extruder).
Defaults to True for unknown models: offering the choice on a printer that
turns out to have one flow type costs the user a redundant dropdown, while
hiding it on one that has two makes half its calibration table
unreachable.
"""
if not model:
return True
normalized = model.strip().upper().replace(" ", "").replace("-", "")
return normalized not in SINGLE_NOZZLE_FLOW_MODELS
def get_rod_type(model: str | None) -> str | None:
"""Return the rod/rail type for a printer model.
@@ -4,9 +4,11 @@ Tests for the BambuMQTTClient service.
These tests focus on timelapse tracking during prints.
"""
import asyncio
import json
import logging
import time
from unittest.mock import MagicMock
import pytest
@@ -6832,6 +6834,302 @@ class TestKProfileResponseDoesNotClobberNozzle:
assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
class TestKProfileNozzleDiameterFromEnvelope:
"""#1748: every K-profile came back as 0.4mm on single-nozzle printers.
``extrusion_cali_get`` carries ``nozzle_diameter`` only on the response
envelope — the per-filament entries hold just setting_id, filament_id,
name, k_value, n_coef and cali_idx. The parser read the field per entry
with a "0.4" default, so a 0.6/0.8 nozzle's profiles were all stamped 0.4.
Beyond the K-Profiles display that broke the cali_idx cascade in the
inventory and Spoolman assign paths, which match on nozzle_diameter.
"""
@pytest.fixture
def mqtt_client(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
return BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="X1ETEST",
access_code="12345678",
)
@staticmethod
def _response(nozzle="0.8", entries=None, seq="48"):
"""A verbatim-shaped extrusion_cali_get payload from the #1748 report."""
if entries is None:
entries = [
{
"setting_id": "GFSNLS02_07",
"filament_id": "GFSNL02",
"name": "SUNLU PLA Matte WHITE 0.8",
"k_value": "0.01750",
"n_coef": "1.000",
"cali_idx": 265,
"is_history_setting": True,
}
]
print_data = {"command": "extrusion_cali_get", "filament_id": "", "filaments": entries}
if nozzle is not None:
print_data["nozzle_diameter"] = nozzle
if seq is not None:
print_data["sequence_id"] = seq
return {"print": print_data}
def test_broadcast_uses_envelope_diameter(self, mqtt_client):
# No request in flight: the unsolicited broadcast still has to record
# the right diameter, because state.kprofiles is what the assign paths
# read when nobody has just fetched.
mqtt_client._process_message(self._response(nozzle="0.8"))
assert [p.nozzle_diameter for p in mqtt_client.state.kprofiles] == ["0.8"]
@pytest.mark.asyncio
async def test_awaited_response_uses_envelope_diameter(self, mqtt_client):
profiles = await self._fetch(mqtt_client, "0.6", self._response(nozzle="0.6", seq="7"))
assert [p.nozzle_diameter for p in profiles] == ["0.6"]
def test_entry_value_still_wins(self, mqtt_client):
# Dual-nozzle firmware does put the field on each entry; that stays
# authoritative, since a batch can legitimately span nozzles.
entries = [{"cali_idx": 1, "filament_id": "GFA00", "name": "PLA", "nozzle_diameter": "0.4"}]
mqtt_client._process_message(self._response(nozzle="0.8", entries=entries))
assert mqtt_client.state.kprofiles[0].nozzle_diameter == "0.4"
def test_empty_entry_value_falls_back_to_envelope(self, mqtt_client):
entries = [{"cali_idx": 1, "filament_id": "GFA00", "name": "PLA", "nozzle_diameter": ""}]
mqtt_client._process_message(self._response(nozzle="0.8", entries=entries))
assert mqtt_client.state.kprofiles[0].nozzle_diameter == "0.8"
def test_no_envelope_value_falls_back_to_default(self, mqtt_client):
# Neither source available: keep the old default rather than let
# str(None) write the literal string "None" into the profile.
mqtt_client._process_message(self._response(nozzle=None))
assert mqtt_client.state.kprofiles[0].nozzle_diameter == "0.4"
@staticmethod
async def _fetch(client, nozzle, response):
"""Run get_kprofiles, feeding `response` in as the printer's answer."""
client.state.connected = True
client._client = MagicMock()
client._client.publish.side_effect = lambda *a, **kw: client._process_message(response)
return await client.get_kprofiles(nozzle_diameter=nozzle, timeout=2.0)
class TestKProfileWriteAcks:
"""#2718: K-profile writes were fire-and-forget.
``set_kprofiles_batch`` published and returned True immediately, and the
printer's ``extrusion_cali_set`` answer was logged at DEBUG and dropped, so
a rejected write was reported to the user as saved. Two facts measured on
real hardware shape the fix: the printer echoes our ``sequence_id`` back
(so the ack can be correlated), and it answers ``result: "fail",
reason: "invalid tray_id"`` to ``tray_id: -1`` on single-nozzle firmware
while applying the write anyway — flipping that field to 0 is what makes
``result`` trustworthy.
"""
@pytest.fixture
def mqtt_client(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
client = BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="X1CTEST",
access_code="12345678",
)
client.state.connected = True
client._client = MagicMock()
return client
@staticmethod
def _sent(client):
return json.loads(client._client.publish.call_args[0][1])["print"]
def test_set_sends_tray_id_zero(self, mqtt_client):
mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
assert self._sent(mqtt_client)["filaments"][0]["tray_id"] == 0
def test_batch_sends_tray_id_zero(self, mqtt_client):
mqtt_client.set_kprofiles_batch([{"filament_id": "GFL99", "name": "t", "k_value": "0.020000"}])
assert self._sent(mqtt_client)["filaments"][0]["tray_id"] == 0
def test_writers_return_their_sequence_id(self, mqtt_client):
seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
assert seq == self._sent(mqtt_client)["sequence_id"]
assert seq in mqtt_client._pending_cali_acks
def test_writers_return_none_when_disconnected(self, mqtt_client):
mqtt_client.state.connected = False
assert mqtt_client.set_kprofile(filament_id="GFL99", name="t", k_value="0.02") is None
assert mqtt_client.set_kprofiles_batch([{"filament_id": "GFL99"}]) is None
assert mqtt_client.delete_kprofile(cali_idx=1, filament_id="GFL99", nozzle_id="HH00-0.4") is None
def test_per_tray_extrusion_cali_set_advances_the_sequence_id(self, mqtt_client):
# It used to reuse the previous command's id, which would silently
# defeat the correlation the write path now depends on.
before = mqtt_client._sequence_id
mqtt_client.extrusion_cali_set(tray_id=0, k_value=0.02)
assert mqtt_client._sequence_id > before
assert self._sent(mqtt_client)["sequence_id"] == str(mqtt_client._sequence_id)
@pytest.mark.asyncio
async def test_failure_ack_is_reported_as_failure(self, mqtt_client):
seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
mqtt_client._process_message(
{
"print": {
"command": "extrusion_cali_set",
"result": "fail",
"reason": "invalid tray_id",
"sequence_id": seq,
}
}
)
ok, detail = await mqtt_client.await_cali_ack(seq, timeout=2.0)
assert ok is False
assert detail == "invalid tray_id"
@pytest.mark.asyncio
async def test_success_ack_passes(self, mqtt_client):
seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
mqtt_client._process_message(
{"print": {"command": "extrusion_cali_set", "result": "success", "reason": "", "sequence_id": seq}}
)
ok, _ = await mqtt_client.await_cali_ack(seq, timeout=2.0)
assert ok is True
@pytest.mark.asyncio
async def test_ack_for_another_write_does_not_resolve_this_one(self, mqtt_client):
seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
mqtt_client._process_message(
{
"print": {
"command": "extrusion_cali_set",
"result": "fail",
"reason": "invalid tray_id",
"sequence_id": "999999",
}
}
)
# Unrelated sequence_id: this write is still unanswered, so it times
# out rather than inheriting someone else's failure.
ok, detail = await mqtt_client.await_cali_ack(seq, timeout=0.3)
assert ok is True
assert "no acknowledgement" in detail
@pytest.mark.asyncio
async def test_silence_is_not_treated_as_rejection(self, mqtt_client):
# Firmware that never answers must not turn every save into an error.
seq = mqtt_client.delete_kprofile(cali_idx=1, filament_id="GFL99", nozzle_id="HH00-0.4")
ok, detail = await mqtt_client.await_cali_ack(seq, timeout=0.3)
assert ok is True
assert "no acknowledgement" in detail
@pytest.mark.asyncio
async def test_pending_slot_is_released(self, mqtt_client):
seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
await mqtt_client.await_cali_ack(seq, timeout=0.3)
assert mqtt_client._pending_cali_acks == {}
def test_delete_ack_is_matched_too(self, mqtt_client):
seq = mqtt_client.delete_kprofile(cali_idx=1, filament_id="GFL99", nozzle_id="HH00-0.4")
mqtt_client._process_message(
{"print": {"command": "extrusion_cali_del", "result": "success", "sequence_id": seq}}
)
assert mqtt_client._pending_cali_acks[seq]["result"] == "success"
class TestKProfileRequestCorrelation:
"""#1748: K-profile requests timed out whenever two were in flight.
Responses were matched to requests by nozzle diameter alone, held in one
shared ``_expected_kprofile_nozzle`` slot. A second request overwrote the
first's expectation, so the first's valid answer was discarded as a
mismatch and that request timed out even though the printer had replied.
Correlation now runs off the sequence_id we send, with the nozzle match
kept as a fallback for firmware that doesn't echo it.
"""
@pytest.fixture
def mqtt_client(self):
from backend.app.services.bambu_mqtt import BambuMQTTClient
client = BambuMQTTClient(
ip_address="192.168.1.100",
serial_number="X1ETEST",
access_code="12345678",
)
client.state.connected = True
client._client = MagicMock()
return client
@staticmethod
def _response(nozzle, seq, name):
return {
"print": {
"command": "extrusion_cali_get",
"nozzle_diameter": nozzle,
"sequence_id": seq,
"filaments": [{"cali_idx": 1, "filament_id": "GFA00", "name": name, "k_value": "0.020000"}],
}
}
@pytest.mark.asyncio
async def test_concurrent_requests_each_get_their_own_response(self, mqtt_client):
# The failing sequence from the report: 0.8 is requested, then 0.4,
# then the 0.8 answer lands. Under nozzle-only matching the expected
# slot already said 0.4, so the 0.8 answer was dropped on the floor.
seen: list[str] = []
def publish(_topic, payload, **_kw):
seen.append(json.loads(payload)["print"]["sequence_id"])
mqtt_client._client.publish.side_effect = publish
big = asyncio.create_task(mqtt_client.get_kprofiles(nozzle_diameter="0.8", timeout=5.0))
small = asyncio.create_task(mqtt_client.get_kprofiles(nozzle_diameter="0.4", timeout=5.0))
await asyncio.sleep(0) # let both publish before either answer arrives
assert len(seen) == 2
mqtt_client._process_message(self._response("0.8", seen[0], "wide"))
mqtt_client._process_message(self._response("0.4", seen[1], "narrow"))
assert [p.name for p in await big] == ["wide"]
assert [p.name for p in await small] == ["narrow"]
@pytest.mark.asyncio
async def test_falls_back_to_nozzle_match_when_sequence_id_is_not_echoed(self, mqtt_client):
# Firmware that answers with its own sequence_id must keep working.
mqtt_client._client.publish.side_effect = lambda *a, **kw: mqtt_client._process_message(
self._response("0.6", "9999", "echoed-nothing")
)
profiles = await mqtt_client.get_kprofiles(nozzle_diameter="0.6", timeout=2.0)
assert [p.name for p in profiles] == ["echoed-nothing"]
@pytest.mark.asyncio
async def test_unrelated_broadcast_does_not_clobber_a_pending_fetch(self, mqtt_client):
# The printer broadcasts 0.4 profiles unsolicited. One arriving while a
# 0.8 fetch is open must neither satisfy nor overwrite it.
def publish(_topic, payload, **_kw):
seq = json.loads(payload)["print"]["sequence_id"]
mqtt_client._process_message(self._response("0.4", "9999", "broadcast"))
mqtt_client._process_message(self._response("0.8", seq, "wanted"))
mqtt_client._client.publish.side_effect = publish
profiles = await mqtt_client.get_kprofiles(nozzle_diameter="0.8", timeout=2.0)
assert [p.name for p in profiles] == ["wanted"]
assert [p.name for p in mqtt_client.state.kprofiles] == ["wanted"]
@pytest.mark.asyncio
async def test_pending_entry_is_released_on_timeout(self, mqtt_client):
# A timed-out attempt must not leave its entry behind, or a later
# broadcast would be matched to a request nobody is waiting on.
profiles = await mqtt_client.get_kprofiles(nozzle_diameter="0.8", timeout=0.01, max_retries=1)
assert profiles == []
assert mqtt_client._pending_kprofile_requests == {}
class TestConnectRefusalReporting:
"""#2698: a refused CONNACK must leave a trace.
+37
View File
@@ -14,6 +14,7 @@ from backend.app.utils.printer_models import (
is_dual_nozzle_model,
normalize_printer_model,
normalize_printer_model_id,
supports_nozzle_flow_type,
)
@@ -213,6 +214,42 @@ class TestDualNozzleModel:
assert is_dual_nozzle_model("") is False
class TestSupportsNozzleFlowType:
"""Which models offer a Standard / High Flow choice on a K-profile.
Mirrors the slicer's own rule — BambuStudio/OrcaSlicer gate their
Nozzle-Flow control on ``len(nozzle_volume) // len(nozzle_diameter) > 1``
read from the machine preset. Evaluated over every bundled Bambu profile,
only the A-series lands on one variant. Getting this wrong in the
permissive direction shows a redundant dropdown; getting it wrong in the
other direction makes half a printer's calibration table unreachable.
"""
def test_a_series_has_one_flow_variant(self):
for model in ("A1", "A1 Mini", "A1MINI", "A2L"):
assert supports_nozzle_flow_type(model) is False, model
def test_a_series_internal_codes(self):
for code in ("N1", "N2S", "N9", "A04", "A11", "A12"):
assert supports_nozzle_flow_type(code) is False, code
def test_single_nozzle_models_still_offer_both_flows(self):
# The split is NOT nozzle count: all of these are single-nozzle and
# all carry two nozzle_volume variants in their machine preset.
for model in ("X1", "X1C", "X1E", "P1P", "P1S", "P2S", "H2S"):
assert supports_nozzle_flow_type(model) is True, model
def test_dual_nozzle_models_offer_both_flows(self):
for model in ("H2D", "H2D Pro", "H2C"):
assert supports_nozzle_flow_type(model) is True, model
def test_unknown_and_empty_default_to_supported(self):
# Fail open: a redundant dropdown beats an unreachable half-table.
assert supports_nozzle_flow_type(None) is True
assert supports_nozzle_flow_type("") is True
assert supports_nozzle_flow_type("SomeFuturePrinter") is True
class TestHasExternalStorage:
"""Pins which Bambu models have a MicroSD slot. The connection
diagnostic flips its ``external_storage`` check from ``fail`` to
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCancellableTimeout } from '../../hooks/useCancellableTimeout';
describe('useCancellableTimeout', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('runs the callback after the delay', () => {
const fn = vi.fn();
const { result } = renderHook(() => useCancellableTimeout());
act(() => result.current.schedule(fn, 1500));
expect(fn).not.toHaveBeenCalled();
act(() => void vi.advanceTimersByTime(1500));
expect(fn).toHaveBeenCalledTimes(1);
});
it('does not run the callback after unmount', () => {
// The bug this exists for: a modal that defers its own close by 1.5s fired
// setState and onClose after the component was gone — which throws outright
// once the DOM around it has been torn down.
const fn = vi.fn();
const { result, unmount } = renderHook(() => useCancellableTimeout());
act(() => result.current.schedule(fn, 1500));
unmount();
act(() => void vi.advanceTimersByTime(5000));
expect(fn).not.toHaveBeenCalled();
});
it('cancel() stops a pending callback', () => {
const fn = vi.fn();
const { result } = renderHook(() => useCancellableTimeout());
act(() => result.current.schedule(fn, 1000));
act(() => result.current.cancel());
act(() => void vi.advanceTimersByTime(2000));
expect(fn).not.toHaveBeenCalled();
});
it('scheduling again replaces the pending callback', () => {
const first = vi.fn();
const second = vi.fn();
const { result } = renderHook(() => useCancellableTimeout());
act(() => result.current.schedule(first, 1000));
act(() => result.current.schedule(second, 1000));
act(() => void vi.advanceTimersByTime(1000));
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(1);
});
it('is safe to cancel when nothing is pending', () => {
const { result } = renderHook(() => useCancellableTimeout());
expect(() => act(() => result.current.cancel())).not.toThrow();
});
});
@@ -1,35 +0,0 @@
import { describe, it, expect } from 'vitest';
import en from '../../i18n/locales/en';
import de from '../../i18n/locales/de';
/**
* Recursively extracts all keys from a nested object as dot-notation paths.
* Example: { foo: { bar: 'baz' } } => ['foo.bar']
*/
const getKeys = (obj: object, prefix = ''): string[] => {
return Object.entries(obj).flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key;
return typeof value === 'object' && value !== null
? getKeys(value, path)
: [path];
});
};
describe('i18n locale parity', () => {
const enKeys = new Set(getKeys(en));
const deKeys = new Set(getKeys(de));
it('German locale has all English keys', () => {
const missingInGerman = [...enKeys].filter((k) => !deKeys.has(k)).sort();
expect(missingInGerman, `Missing ${missingInGerman.length} key(s) in German locale`).toEqual([]);
});
it('English locale has all German keys', () => {
const missingInEnglish = [...deKeys].filter((k) => !enKeys.has(k)).sort();
expect(missingInEnglish, `Missing ${missingInEnglish.length} key(s) in English locale`).toEqual([]);
});
it('both locales have the same number of keys', () => {
expect(enKeys.size).toBe(deKeys.size);
});
});
@@ -3,9 +3,14 @@
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import { act, fireEvent, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { render } from '../utils';
import { ThemeProvider } from '../../contexts/ThemeContext';
import { ToastProvider } from '../../contexts/ToastContext';
import { AuthProvider } from '../../contexts/AuthContext';
import { SettingsPage } from '../../pages/SettingsPage';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
@@ -1457,3 +1462,145 @@ describe('SettingsPage — sponsor banner audience', () => {
expect(screen.getByText(/6 printers/i)).toBeInTheDocument();
});
});
describe('SettingsPage — settings changed outside the page (#2716)', () => {
const restoreLabel = 'Restore plate for finish photo';
// external_url is deliberately populated: when the server has none the page
// detects one from the browser and saves it unprompted, which would show up
// as a PUT in tests that assert none was made. That behaviour has its own
// test at the end of this block.
const baseSettings = { ...mockSettings, external_url: window.location.origin };
let queryClient: QueryClient;
let puts: Record<string, unknown>[];
let served: Record<string, unknown>;
function renderPage() {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } },
});
return rtlRender(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<ThemeProvider>
<ToastProvider>
<SettingsPage />
</ToastProvider>
</ThemeProvider>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
);
}
/** Change the settings row server-side and let the page's query observe it. */
async function changeOnServer(patch: Record<string, unknown>) {
served = { ...served, ...patch };
await act(async () => {
await queryClient.invalidateQueries({ queryKey: ['settings'] });
});
}
/** Wait out the 100ms initial-load suppression, then flip a checkbox. */
async function toggleRestorePlate() {
const label = await screen.findByText(restoreLabel);
await new Promise((resolve) => setTimeout(resolve, 200));
const row = label.closest('div')!.parentElement!;
await userEvent.click(within(row).getByRole('checkbox'));
}
beforeEach(() => {
window.history.replaceState({}, '', '/');
localStorage.clear();
setAuthToken(null);
puts = [];
served = { ...baseSettings };
server.use(
http.get('/api/v1/settings/', () => HttpResponse.json(served)),
http.put('/api/v1/settings/', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
puts.push(body);
served = { ...served, ...body };
return HttpResponse.json(served);
})
);
});
it('does not write its stale copy back over a server-side change', async () => {
// The defect: the page diffed the live query cache against its own copy, so
// a refetch that carried someone else's change read as a local edit and was
// reverted ~500ms later with no user interaction at all.
renderPage();
await screen.findByText(restoreLabel);
await new Promise((resolve) => setTimeout(resolve, 200));
await changeOnServer({ currency: 'EUR' });
// Well past the 500ms debounce.
await new Promise((resolve) => setTimeout(resolve, 1200));
expect(puts).toEqual([]);
});
it('adopts the server value, so a later save carries it rather than the stale one', async () => {
renderPage();
await new Promise((resolve) => setTimeout(resolve, 200));
await changeOnServer({ currency: 'EUR' });
await toggleRestorePlate();
await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
// The user's edit is saved...
expect(puts[0].finish_photo_restore_plate).toBe(false);
// ...and the field they never touched goes back as the server's value, not
// the USD the page loaded with.
expect(puts[0].currency).toBe('EUR');
});
it('never reverts a pending user edit that the server changed too', async () => {
renderPage();
await toggleRestorePlate();
// Lands while the edit is still sitting in the 500ms debounce, i.e. before
// the page has committed it. Adopting the server's value here would throw
// the edit away silently.
await changeOnServer({ finish_photo_restore_plate: true });
await waitFor(() => expect(puts.length).toBeGreaterThan(0), { timeout: 3000 });
await new Promise((resolve) => setTimeout(resolve, 1200));
// Asserted over every request rather than a particular one: whichever order
// the refetch and the debounce happen to land in, no write may carry the
// server's value back over the user's.
expect(puts.map((p) => p.finish_photo_restore_plate)).toEqual(puts.map(() => false));
});
it('saves once per edit — the baseline moves with the saved row', async () => {
// Guards the failure mode the baseline introduces if it is not advanced on
// save: every render would diff against the pre-save snapshot and re-send.
renderPage();
await toggleRestorePlate();
await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
await new Promise((resolve) => setTimeout(resolve, 1500));
expect(puts).toHaveLength(1);
});
it('still persists the external_url it detects from the browser', async () => {
// The page seeds external_url from window.location.origin when the server
// has none and relies on the auto-save to persist it. That only works
// because the baseline is the raw server row: seed the baseline from the
// adjusted copy instead and the detected URL matches it, so nothing ever
// marks it as needing a save.
served = { ...mockSettings };
renderPage();
await screen.findByText(restoreLabel);
await new Promise((resolve) => setTimeout(resolve, 200));
// A refetch carrying a field this page does not manage. It is enough to
// re-run the diff, and the only thing that differs is the detected URL.
await changeOnServer({ spoolman_url: 'http://spoolman.example' });
await waitFor(() => expect(puts).toHaveLength(1), { timeout: 3000 });
expect(puts[0].external_url).toBe(window.location.origin);
});
});
@@ -0,0 +1,346 @@
import { describe, it, expect, vi } from 'vitest';
import {
buildFilamentPresetOptions,
genericFilamentIdForMaterial,
presetDisplayName,
resolveFilamentId,
} from '../../utils/filamentPresets';
import type { BuiltinFilament, LocalPreset, OrcaProfileMeta, SlicerSetting } from '../../api/client';
const localPreset = (over: Partial<LocalPreset> = {}): LocalPreset => ({
id: 1,
name: 'Elegoo PLA+ @BBL X1C 0.4 nozzle',
preset_type: 'filament',
source: 'orca',
filament_type: 'PLA',
filament_vendor: 'Elegoo',
nozzle_temp_min: 190,
nozzle_temp_max: 230,
pressure_advance: null,
default_filament_colour: null,
filament_cost: null,
filament_density: null,
compatible_printers: null,
inherits: null,
version: null,
created_at: '',
updated_at: '',
...over,
});
const orcaProfile = (over: Partial<OrcaProfileMeta> = {}): OrcaProfileMeta => ({
setting_id: 'a1b2c3',
name: 'Sunlu PETG',
type: 'filament',
version: null,
user_id: null,
updated_time: null,
is_custom: true,
...over,
});
const cloudSetting = (over: Partial<SlicerSetting> = {}): SlicerSetting => ({
setting_id: 'GFSA00',
name: 'Bambu PLA Basic @BBL X1C',
type: 'filament',
version: null,
user_id: null,
updated_time: null,
is_custom: false,
...over,
});
const builtin = (filament_id: string, name: string): BuiltinFilament => ({ filament_id, name });
describe('genericFilamentIdForMaterial', () => {
it('maps an exact material', () => {
expect(genericFilamentIdForMaterial('PETG')).toBe('GFG99');
});
it('is case and whitespace tolerant', () => {
expect(genericFilamentIdForMaterial(' pla ')).toBe('GFL99');
});
it('falls back to the base material when a suffix is unknown', () => {
// "PLA-GF" has no generic of its own; the PLA generic is the honest answer.
expect(genericFilamentIdForMaterial('PLA-GF')).toBe('GFL99');
});
it('returns empty rather than guessing for an unknown material', () => {
expect(genericFilamentIdForMaterial('UNOBTANIUM')).toBe('');
expect(genericFilamentIdForMaterial('')).toBe('');
expect(genericFilamentIdForMaterial(null)).toBe('');
});
});
describe('presetDisplayName', () => {
it('strips the printer/nozzle suffix', () => {
expect(presetDisplayName('Bambu PLA Basic @BBL X1C 0.4 nozzle')).toBe('Bambu PLA Basic');
});
it('strips the custom-preset marker', () => {
expect(presetDisplayName('# My PLA @BBL P1S')).toBe('My PLA');
});
});
describe('buildFilamentPresetOptions', () => {
it('is empty when every source is', () => {
expect(buildFilamentPresetOptions({})).toEqual([]);
});
it('ranks the tiers local > orca > cloud > builtin', () => {
const options = buildFilamentPresetOptions({
builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic')],
cloudSettings: [cloudSetting({ setting_id: 'GFSB99', name: 'Generic ABS' })],
orcaProfiles: [orcaProfile()],
localPresets: [localPreset()],
});
expect(options.map(o => o.source)).toEqual(['local', 'orca_cloud', 'cloud', 'builtin']);
});
it('sorts by name inside a tier', () => {
const options = buildFilamentPresetOptions({
builtinFilaments: [builtin('GFA01', 'Bambu PLA Matte'), builtin('GFA00', 'Bambu PLA Basic')],
});
expect(options.map(o => o.name)).toEqual(['Bambu PLA Basic', 'Bambu PLA Matte']);
});
it('takes a builtin filament id straight from the table', () => {
const [option] = buildFilamentPresetOptions({ builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic')] });
expect(option).toMatchObject({ id: 'builtin_GFA00', filamentId: 'GFA00' });
});
it('derives a Bambu official cloud preset id from its setting_id', () => {
const [option] = buildFilamentPresetOptions({ cloudSettings: [cloudSetting({ setting_id: 'GFSG98_09' })] });
expect(option.filamentId).toBe('GFG98');
});
it('leaves a cloud user preset unresolved for the detail lookup', () => {
// PFUS ids are setting ids, not filament ids — the printer rejects them,
// so guessing one here would file the calibration under nothing.
const [option] = buildFilamentPresetOptions({
cloudSettings: [cloudSetting({ setting_id: 'PFUS9ac902733670a9', name: 'My PETG', is_custom: true })],
});
expect(option.filamentId).toBe('');
});
it('gives local and orca presets the generic id for their material', () => {
const options = buildFilamentPresetOptions({
localPresets: [localPreset({ filament_type: 'PETG' })],
orcaProfiles: [orcaProfile({ name: 'Sunlu ABS @BBL X1C' })],
});
expect(options.find(o => o.source === 'local')?.filamentId).toBe('GFG99');
expect(options.find(o => o.source === 'orca_cloud')?.filamentId).toBe('GFB99');
});
it('parses the material from the name when a local preset declares none', () => {
const [option] = buildFilamentPresetOptions({
localPresets: [localPreset({ filament_type: null, name: 'Overture TPU @BBL X1C' })],
});
expect(option.filamentId).toBe('GFU99');
});
it('collapses a cloud filament duplicated once per printer model', () => {
// Every Bambu Cloud account carries one copy per model. They share a
// filament id and, with the "@…" suffix stripped, one visible name.
const options = buildFilamentPresetOptions({
cloudSettings: [
cloudSetting({ setting_id: 'GFSA00_00', name: 'Bambu PLA Basic @BBL X1C' }),
cloudSetting({ setting_id: 'GFSA00_01', name: 'Bambu PLA Basic @BBL P1S' }),
cloudSetting({ setting_id: 'GFSA00_02', name: 'Bambu PLA Basic @BBL A1' }),
],
});
expect(options).toHaveLength(1);
expect(options[0]).toMatchObject({ name: 'Bambu PLA Basic', filamentId: 'GFA00' });
});
it('collapses a cloud user preset duplicated per model, which has no filament id to key on', () => {
const options = buildFilamentPresetOptions({
cloudSettings: [
cloudSetting({ setting_id: 'PFUSaaa', name: 'My PETG @BBL X1C', is_custom: true }),
cloudSetting({ setting_id: 'PFUSbbb', name: 'My PETG @BBL P1S', is_custom: true }),
],
});
expect(options).toHaveLength(1);
});
it('keeps distinct cloud filaments apart', () => {
const options = buildFilamentPresetOptions({
cloudSettings: [
cloudSetting({ setting_id: 'GFSA00_00', name: 'Bambu PLA Basic @BBL X1C' }),
cloudSetting({ setting_id: 'GFSA01_00', name: 'Bambu PLA Matte @BBL X1C' }),
],
});
expect(options.map(o => o.name)).toEqual(['Bambu PLA Basic', 'Bambu PLA Matte']);
});
it('collapses one imported filament re-imported for several printers', () => {
const options = buildFilamentPresetOptions({
localPresets: [
localPreset({ id: 1, name: 'Elegoo PLA+ @BBL X1C 0.4 nozzle' }),
localPreset({ id: 2, name: 'Elegoo PLA+ @BBL P1S 0.4 nozzle' }),
],
});
expect(options).toHaveLength(1);
});
it('keeps imported presets of different materials that share a generic id path', () => {
// Keyed by name, not by generic id — otherwise two distinct PLA imports
// would collapse into one because both map to GFL99.
const options = buildFilamentPresetOptions({
localPresets: [
localPreset({ id: 1, name: 'Elegoo PLA+' }),
localPreset({ id: 2, name: 'Polymaker PolyLite PLA' }),
],
});
expect(options).toHaveLength(2);
});
it('collapses Orca Cloud copies of one filament', () => {
const options = buildFilamentPresetOptions({
orcaProfiles: [
orcaProfile({ setting_id: 'u1', name: 'Sunlu PETG @BBL X1C' }),
orcaProfile({ setting_id: 'u2', name: 'Sunlu PETG @BBL A1' }),
],
});
expect(options).toHaveLength(1);
});
it('drops a builtin the cloud tier covers under a variant setting_id', () => {
// Cloud ids carry a "_NN" variant suffix; without normalising it the
// builtin tier lists the same filament a second time.
const options = buildFilamentPresetOptions({
cloudSettings: [cloudSetting({ setting_id: 'GFSA00_01', name: 'Bambu PLA Basic @BBL X1C' })],
builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic'), builtin('GFA01', 'Bambu PLA Matte')],
});
expect(options.filter(o => o.source === 'builtin').map(o => o.filamentId)).toEqual(['GFA01']);
});
it('drops a builtin already offered by a cloud tier, matching the S-infix spelling', () => {
const options = buildFilamentPresetOptions({
cloudSettings: [cloudSetting({ setting_id: 'GFSA00', name: 'Bambu PLA Basic' })],
builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic'), builtin('GFA01', 'Bambu PLA Matte')],
});
expect(options.filter(o => o.source === 'builtin').map(o => o.filamentId)).toEqual(['GFA01']);
});
it('drops a bambu cloud preset Orca Cloud already covers', () => {
const options = buildFilamentPresetOptions({
orcaProfiles: [orcaProfile({ setting_id: 'shared-id' })],
cloudSettings: [cloudSetting({ setting_id: 'shared-id' })],
});
expect(options.map(o => o.source)).toEqual(['orca_cloud']);
});
it('keeps an Orca Cloud library that overlaps an imported bundle by name', () => {
// These are usually the same profiles reached two ways. Letting the
// imported tier claim the name emptied the Orca Cloud group down to
// whatever happened not to be imported too.
const options = buildFilamentPresetOptions({
localPresets: [
localPreset({ id: 1, name: 'Elegoo PLA+ @BBL X1C' }),
localPreset({ id: 2, name: 'Sunlu PETG @BBL X1C' }),
],
orcaProfiles: [
orcaProfile({ setting_id: 'u1', name: 'Elegoo PLA+ @BBL X1C' }),
orcaProfile({ setting_id: 'u2', name: 'Sunlu PETG @BBL X1C' }),
],
});
expect(options.filter(o => o.source === 'local')).toHaveLength(2);
expect(options.filter(o => o.source === 'orca_cloud')).toHaveLength(2);
});
it('still drops a cross-tier row that carries an id a higher tier claimed', () => {
// A shared id is true identity, unlike a shared name.
const options = buildFilamentPresetOptions({
orcaProfiles: [orcaProfile({ setting_id: 'shared-id', name: 'Sunlu PETG' })],
cloudSettings: [cloudSetting({ setting_id: 'shared-id', name: 'Something Else' })],
});
expect(options.map(o => o.source)).toEqual(['orca_cloud']);
});
it('never echoes a filament the tiers above already offered back from the builtin table', () => {
// The builtin tier is a static copy of the same Bambu catalogue, so
// without a name check it re-listed everything under a fourth heading.
const options = buildFilamentPresetOptions({
localPresets: [localPreset({ name: 'Bambu PLA Basic @BBL X1C 0.4 nozzle' })],
builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic'), builtin('GFA01', 'Bambu PLA Matte')],
});
expect(options.map(o => [o.source, o.name])).toEqual([
['local', 'Bambu PLA Basic'],
['builtin', 'Bambu PLA Matte'],
]);
});
it('suppresses a builtin a cloud tier already named, even with no id overlap', () => {
const options = buildFilamentPresetOptions({
cloudSettings: [cloudSetting({ setting_id: 'PFUSaaa', name: 'Bambu PLA Basic @BBL X1C', is_custom: true })],
builtinFilaments: [builtin('GFA00', 'Bambu PLA Basic')],
});
expect(options.map(o => o.source)).toEqual(['cloud']);
});
it('does not let one import swallow every filament of the same material', () => {
// Imports resolve to a shared generic id (all PLA → GFL99); claiming that
// id would hide every other PLA behind the first one imported.
const options = buildFilamentPresetOptions({
localPresets: [
localPreset({ id: 1, name: 'Elegoo PLA+', filament_type: 'PLA' }),
localPreset({ id: 2, name: 'Polymaker PolyLite PLA', filament_type: 'PLA' }),
],
builtinFilaments: [builtin('GFL99', 'Generic PLA')],
});
expect(options.map(o => o.name)).toEqual([
'Elegoo PLA+',
'Polymaker PolyLite PLA',
'Generic PLA',
]);
});
it('strips printer suffixes from displayed names', () => {
const [option] = buildFilamentPresetOptions({ localPresets: [localPreset()] });
expect(option.name).toBe('Elegoo PLA+');
});
});
describe('resolveFilamentId', () => {
const option = (over = {}) => ({
id: 'PFUS9ac902733670a9',
name: 'My PETG',
source: 'cloud' as const,
filamentId: '',
filamentType: 'PETG',
...over,
});
it('returns an already-known id without fetching', async () => {
const fetchDetail = vi.fn();
await expect(resolveFilamentId(option({ filamentId: 'GFA00' }), fetchDetail)).resolves.toBe('GFA00');
expect(fetchDetail).not.toHaveBeenCalled();
});
it('fetches the cloud detail for a user preset', async () => {
const fetchDetail = vi.fn().mockResolvedValue({ filament_id: 'P285e239' });
await expect(resolveFilamentId(option(), fetchDetail)).resolves.toBe('P285e239');
expect(fetchDetail).toHaveBeenCalledWith('PFUS9ac902733670a9');
});
it('returns empty when the detail carries no filament_id', async () => {
// Never fall back to base_id: that collapses a custom preset onto the
// generic it inherits from (#1053).
const fetchDetail = vi.fn().mockResolvedValue({ base_id: 'GFSG98_09' });
await expect(resolveFilamentId(option(), fetchDetail)).resolves.toBe('');
});
it('returns empty when the detail lookup fails', async () => {
const fetchDetail = vi.fn().mockRejectedValue(new Error('offline'));
await expect(resolveFilamentId(option(), fetchDetail)).resolves.toBe('');
});
it('does not fetch for a non-cloud tier that resolved to nothing', async () => {
const fetchDetail = vi.fn();
const unknown = option({ source: 'local' as const, filamentType: 'UNOBTANIUM' });
await expect(resolveFilamentId(unknown, fetchDetail)).resolves.toBe('');
expect(fetchDetail).not.toHaveBeenCalled();
});
});
+4
View File
@@ -357,6 +357,10 @@ export interface Printer {
model: string | null;
location: string | null; // Group/location name
nozzle_count: number; // 1 or 2, auto-detected from MQTT
// Model is sold with both Standard and High Flow nozzles, so a K-profile's
// flow type is a real choice. Derived from the model, not the nozzle count —
// only the A-series has a single variant.
supports_nozzle_flow_type: boolean;
is_active: boolean;
auto_archive: boolean;
external_camera_url: string | null;
@@ -8,6 +8,7 @@ import { matchesPrinterModelSuffix, presetCompatibility, buildCompatibilityIndex
import { toFilamentId } from './spool-form/utils';
import { Button } from './Button';
import { getAmsLabel } from '../utils/amsHelpers';
import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
interface SlotInfo {
amsId: number;
@@ -305,6 +306,9 @@ export function ConfigureAmsSlotModal({
const [showSuccess, setShowSuccess] = useState(false);
const [showExtendedColors, setShowExtendedColors] = useState(false);
const scrolledToRef = useRef<string>('');
// The success state is held briefly before the modal closes itself; that
// timer must not outlive the modal.
const { schedule: scheduleClose } = useCancellableTimeout();
// Fetch cloud settings (gracefully handle 401 when logged out)
const { data: cloudSettings, isLoading: settingsLoading, isError: cloudError } = useQuery({
@@ -614,7 +618,7 @@ export function ConfigureAmsSlotModal({
setShowSuccess(true);
onSuccess?.();
// Close after showing success briefly
setTimeout(() => {
scheduleClose(() => {
setShowSuccess(false);
onClose();
}, 1500);
@@ -629,7 +633,7 @@ export function ConfigureAmsSlotModal({
onSuccess: () => {
setShowSuccess(true);
onSuccess?.();
setTimeout(() => {
scheduleClose(() => {
setShowSuccess(false);
onClose();
}, 1500);
+269 -113
View File
@@ -21,10 +21,17 @@ import {
} from 'lucide-react';
import { api } from '../api/client';
import type { KProfile, KProfileCreate, KProfileDelete, Permission } from '../api/client';
import {
buildFilamentPresetOptions,
resolveFilamentId,
type FilamentPresetOption,
type FilamentPresetSource,
} from '../utils/filamentPresets';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import { useCancellableTimeout } from '../hooks/useCancellableTimeout';
interface KProfileCardProps {
profile: KProfile;
@@ -42,18 +49,26 @@ const truncateK = (value: string) => {
return (Math.trunc(num * 1000) / 1000).toFixed(3);
};
// Get flow type label from nozzle_id (e.g., "HH00-0.4" -> "HF", "HS00-0.4" -> "S")
const getFlowTypeLabel = (nozzleId: string) => {
if (nozzleId.startsWith('HH')) return 'HF'; // High Flow
return 'S'; // Standard Flow (default)
};
// nozzle_id encodes the flow type, per the slicer's own generator:
// "H" + (Standard ? "S" : "H") + "00" + "-" + diameter
// so "HS00-0.4" is Standard and "HH00-0.4" is High Flow. The "00" is a literal,
// not a material code.
const STANDARD_FLOW = 'HS00';
const HIGH_FLOW = 'HH00';
// Extract nozzle type prefix from nozzle_id (e.g., "HH00-0.4" -> "HH00")
// Many printers omit nozzle_id from their extrusion_cali_get response entirely
// (#1748) — the field simply isn't in the payload. BambuStudio treats that as
// Standard (its parser falls back to nvtStandard when the key is absent), and
// so do we: the flow type stays a real, editable value rather than a blank.
const getNozzleTypePrefix = (nozzleId: string) => {
const match = nozzleId.match(/^([A-Z]{2}\d{2})/);
return match ? match[1] : 'HH00';
return match ? match[1] : STANDARD_FLOW;
};
// Short label for the profile list.
const getFlowTypeLabel = (nozzleId: string) =>
getNozzleTypePrefix(nozzleId) === HIGH_FLOW ? 'HF' : 'S';
// Extract filament name from profile name (e.g., "High Flow_Devil Design PLA Basic" -> "Devil Design PLA Basic")
const extractFilamentName = (profileName: string) => {
// Profile names are formatted as "{Flow Type}_{Filament Name}" or "{Flow Type} {Filament Name}"
@@ -149,9 +164,11 @@ interface KProfileModalProps {
profile?: KProfile;
printerId: number;
nozzleDiameter: string;
existingProfiles?: KProfile[]; // Existing profiles for filament selection
existingProfiles?: KProfile[]; // Existing profiles, used for name resolution
builtinFilaments?: { filament_id: string; name: string }[]; // Filament ID → name lookup
filamentPresets?: FilamentPresetOption[]; // Every filament this install knows, tiered
isDualNozzle?: boolean; // Whether this is a dual-nozzle printer
supportsFlowType?: boolean; // Model sells both Standard and High Flow nozzles
initialNote?: string; // Initial note value for the profile
initialNoteKey?: string | null; // Key the note was stored under (for clearing)
onClose: () => void;
@@ -166,7 +183,9 @@ function KProfileModal({
nozzleDiameter,
existingProfiles = [],
builtinFilaments = [],
filamentPresets = [],
isDualNozzle = false,
supportsFlowType = true,
initialNote = '',
initialNoteKey = null,
onClose,
@@ -181,10 +200,19 @@ function KProfileModal({
const [kValue, setKValue] = useState(
profile?.k_value ? truncateK(profile.k_value) : '0.020'
);
const [filamentId, setFilamentId] = useState(profile?.filament_id || '');
// What the Filament select is bound to. When editing, the printer's own
// filament_id (the select is read-only). For a new profile, the *preset
// handle* from the tiered list — a local row id, an Orca UUID, a Bambu Cloud
// setting_id or a builtin filament id — which is resolved to a real
// filament_id on submit, since only some tiers carry one directly.
const [filamentChoice, setFilamentChoice] = useState(profile?.filament_id || '');
// Split nozzle into type and diameter
// Both selects are read-only while editing: they report what the printer
// holds, they don't set it. '' means the printer reported no nozzle_id, which
// single-nozzle models never do (#1748) — showing "High Flow" there was the
// UI inventing a value the printer never sent.
const [nozzleType, setNozzleType] = useState(
profile?.nozzle_id ? getNozzleTypePrefix(profile.nozzle_id) : 'HH00'
profile ? getNozzleTypePrefix(profile.nozzle_id) : STANDARD_FLOW
);
const [modalDiameter, setModalDiameter] = useState(
profile?.nozzle_diameter || nozzleDiameter
@@ -197,40 +225,49 @@ function KProfileModal({
const [isSyncing, setIsSyncing] = useState(false);
const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
const [note, setNote] = useState(initialNote);
const [filamentQuery, setFilamentQuery] = useState('');
// The modal defers its own close so the printer has time to process the
// command; that timer must not outlive the modal.
const { schedule: scheduleClose } = useCancellableTimeout();
// Extract unique filaments from existing K-profiles on the printer
// Use builtin filament table for accurate name resolution (filament_id → name)
// Falls back to extracting from profile name for custom/unknown presets
const knownFilaments = React.useMemo(() => {
// Build lookup map from builtin filament names (includes cloud presets from parent)
const builtinMap = new Map<string, string>();
for (const bf of builtinFilaments) {
builtinMap.set(bf.filament_id, bf.name);
}
// Name for the filament an existing profile is bound to. The builtin table
// (which the parent has already merged with the user's cloud presets) is
// authoritative; a profile whose filament_id is in neither falls back to the
// name the printer stored for it.
const editedFilamentName = React.useMemo(() => {
if (!profile?.filament_id) return '';
const builtinName = builtinFilaments.find(bf => bf.filament_id === profile.filament_id)?.name;
if (builtinName) return builtinName;
const fromProfile = existingProfiles.find(p => p.filament_id === profile.filament_id);
return extractFilamentName(fromProfile?.name || profile.name || '') || profile.filament_id;
}, [profile, existingProfiles, builtinFilaments]);
const filamentMap = new Map<string, { id: string; name: string }>();
for (const p of existingProfiles) {
if (p.filament_id && !filamentMap.has(p.filament_id)) {
// Prefer builtin name (accurate), fall back to extracting from profile name
const builtinName = builtinMap.get(p.filament_id);
const filamentName = builtinName || extractFilamentName(p.name || '');
filamentMap.set(p.filament_id, {
id: p.filament_id,
name: filamentName || p.filament_id,
});
}
}
return Array.from(filamentMap.values()).sort((a, b) =>
a.name.localeCompare(b.name)
);
}, [existingProfiles, builtinFilaments]);
// The tiered list, grouped for rendering. Order is fixed app-wide —
// imported, then Orca Cloud, then Bambu Cloud, then the hardcoded table —
// and buildFilamentPresetOptions has already sorted by it, so grouping is
// just a partition that preserves that order.
const presetGroups = React.useMemo(() => {
const labels: [FilamentPresetSource, string][] = [
['local', t('kProfiles.modal.source.local')],
['orca_cloud', t('kProfiles.modal.source.orcaCloud')],
['cloud', t('kProfiles.modal.source.bambuCloud')],
['builtin', t('kProfiles.modal.source.builtin')],
];
const query = filamentQuery.trim().toLowerCase();
const matches = query
? filamentPresets.filter(p => p.name.toLowerCase().includes(query))
: filamentPresets;
return labels
.map(([source, label]) => ({ source, label, items: matches.filter(p => p.source === source) }))
.filter(g => g.items.length > 0);
}, [filamentPresets, filamentQuery, t]);
const saveMutation = useMutation({
mutationFn: (data: KProfileCreate) => {
console.log('[KProfile] Calling API...');
return api.setKProfile(printerId, data);
},
onSuccess: (result) => {
onSuccess: (result, variables) => {
console.log('[KProfile] Save success:', result);
showToast(t('kProfiles.toast.profileSaved'));
// Save note if it changed (including clearing it)
@@ -243,8 +280,10 @@ function KProfileModal({
// Editing: use setting_id if available, or composite key with slot_id
profileKey = profile.setting_id || `slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`;
} else {
// New profile: use name as key (will be matched when profile is loaded)
profileKey = `name_${name}_${filamentId}`;
// New profile: use name as key (matched against the reloaded profile,
// so it has to be the resolved filament_id that was sent — not the
// preset handle the user picked).
profileKey = `name_${name}_${variables.filament_id}`;
}
onSaveNote(profileKey, note);
}
@@ -252,7 +291,7 @@ function KProfileModal({
setIsSyncing(true);
// Add delay before closing to give printer time to process the save
// onSave will trigger refetch in the parent component
setTimeout(() => {
scheduleClose(() => {
setIsSyncing(false);
onSave();
}, 2500);
@@ -276,7 +315,7 @@ function KProfileModal({
setIsSyncing(true);
// Add longer delay for delete - printer needs more time to process
// before it can return the updated profile list
setTimeout(() => {
scheduleClose(() => {
setIsSyncing(false);
onClose();
}, 4000);
@@ -316,14 +355,46 @@ function KProfileModal({
// Combine nozzle type and diameter into nozzle_id (e.g., "HH00-0.4")
const nozzleId = `${nozzleType}-${modalDiameter}`;
// An edit is delete + re-add on single-nozzle printers, so the nozzle
// fields have to survive the round trip — both selects are disabled while
// editing. Rebuilding them blindly from the selects is what let a 0.6mm
// profile come back as "HH00-0.4" once the parse defaults had stamped it
// 0.4 (#1748), so prefer whatever the printer reported. Where it reported
// no nozzle_id at all, send the rebuilt one rather than an empty string —
// the field is part of the profile's identity on the wire and the slicer
// always populates it.
const editNozzleId = profile ? profile.nozzle_id || nozzleId : nozzleId;
const editDiameter = profile ? profile.nozzle_diameter : modalDiameter;
// The printer indexes its calibration table by filament_id, so the preset
// the user picked has to be reduced to one before anything is sent. Only
// the builtin tier and Bambu's official cloud presets carry one outright;
// a cloud *user* preset needs its detail fetched, and imported / Orca
// presets have no Bambu id at all and map to the generic for their
// material. Refuse rather than guess when nothing resolves — a profile
// filed under the wrong filament is invisible to the slot that needs it.
let resolvedFilamentId = profile?.filament_id || '';
if (!profile) {
const picked = filamentPresets.find(p => p.id === filamentChoice);
if (!picked) {
showToast(t('kProfiles.toast.selectFilament'), 'error');
return;
}
resolvedFilamentId = await resolveFilamentId(picked, api.getCloudSettingDetail);
if (!resolvedFilamentId) {
showToast(t('kProfiles.toast.filamentNotResolvable', { name: picked.name }), 'error');
return;
}
}
// For editing or single extruder: just save one profile
if (profile || selectedExtruders.length === 1) {
const payload = {
name: name,
k_value: formattedKValue,
filament_id: filamentId,
nozzle_id: nozzleId,
nozzle_diameter: modalDiameter,
filament_id: resolvedFilamentId,
nozzle_id: editNozzleId,
nozzle_diameter: editDiameter,
extruder_id: profile ? profile.extruder_id : selectedExtruders[0],
setting_id: profile?.setting_id,
slot_id: profile?.slot_id ?? 0,
@@ -341,7 +412,7 @@ function KProfileModal({
const batchPayload = selectedExtruders.map(extruderId => ({
name: name,
k_value: formattedKValue,
filament_id: filamentId,
filament_id: resolvedFilamentId,
nozzle_id: nozzleId,
nozzle_diameter: modalDiameter,
extruder_id: extruderId,
@@ -356,7 +427,7 @@ function KProfileModal({
showToast(t('kProfiles.toast.profilesSaved', { count: selectedExtruders.length }));
// Save note for new batch profiles
if (onSaveNote && note) {
const profileKey = `name_${name}_${filamentId}`;
const profileKey = `name_${name}_${resolvedFilamentId}`;
onSaveNote(profileKey, note);
}
} catch (error) {
@@ -370,7 +441,7 @@ function KProfileModal({
setSavingProgress({ current: selectedExtruders.length, total: selectedExtruders.length });
// Wait for final sync before closing
// onSave will trigger refetch in the parent component
setTimeout(() => {
scheduleClose(() => {
setIsSyncing(false);
setSavingProgress({ current: 0, total: 0 });
onSave();
@@ -454,49 +525,77 @@ function KProfileModal({
{/* Filament - read-only when editing */}
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.filament')}</label>
<select
value={filamentId}
onChange={(e) => {
const newFilamentId = e.target.value;
setFilamentId(newFilamentId);
// Auto-generate profile name when filament is selected (for new profiles)
// Only auto-generate if name is empty - don't overwrite user input
if (!profile && newFilamentId && !name) {
const selectedFilament = knownFilaments.find(f => f.id === newFilamentId);
if (selectedFilament) {
const flowLabel = nozzleType === 'HH00' ? 'HF' : 'S';
setName(`${flowLabel} ${selectedFilament.name}`);
}
}
}}
disabled={!!profile}
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
required={!profile}
>
<option value="">{t('kProfiles.modal.selectFilament')}</option>
{/* Show current filament when editing - look up from knownFilaments */}
{profile?.filament_id && (
<option key={profile.filament_id} value={profile.filament_id}>
{knownFilaments.find(f => f.id === profile.filament_id)?.name || profile.filament_id}
</option>
)}
{/* Show known filaments from existing K-profiles (for new profiles) */}
{!profile && knownFilaments.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
{!profile && knownFilaments.length === 0 && (
<p className="text-xs text-bambu-gray mt-1">
{t('kProfiles.modal.noFilamentsHelp')}
</p>
{profile ? (
// Editing or copying: the filament is fixed, so this is a
// readout rather than a control.
<div className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white opacity-60">
{editedFilamentName || profile.filament_id}
</div>
) : (
// A real list rather than a <select>: Chrome ignores almost
// every CSS property on <optgroup>, so a source heading inside
// a native dropdown can't be made to stand out.
<div className="border border-bambu-dark-tertiary rounded-lg overflow-hidden">
<div className="relative border-b border-bambu-dark-tertiary">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
value={filamentQuery}
onChange={(e) => setFilamentQuery(e.target.value)}
placeholder={t('kProfiles.modal.searchFilaments')}
className="w-full pl-10 pr-3 py-2 bg-bambu-dark text-white placeholder-bambu-gray focus:outline-none"
/>
</div>
<div className="max-h-56 overflow-y-auto bg-bambu-dark">
{presetGroups.length === 0 ? (
<p className="px-3 py-3 text-xs text-bambu-gray">
{filamentPresets.length === 0
? t('kProfiles.modal.noFilamentsHelp')
: t('kProfiles.modal.noFilamentMatches')}
</p>
) : presetGroups.map((group) => (
<div key={group.source}>
<div className="sticky top-0 z-10 flex items-center gap-2 px-3 py-1.5 bg-bambu-dark-secondary border-y border-bambu-dark-tertiary">
<span className="text-xs font-bold uppercase tracking-wider text-bambu-green">
{group.label}
</span>
<span className="text-[10px] text-bambu-gray">{group.items.length}</span>
</div>
{group.items.map((f) => (
<button
key={f.id}
type="button"
onClick={() => {
setFilamentChoice(f.id);
// Auto-generate the profile name, but never over
// an entry the user typed.
if (!name) {
const flowLabel = nozzleType === HIGH_FLOW ? 'HF' : 'S';
setName(`${flowLabel} ${f.name}`);
}
}}
className={`w-full text-left px-3 py-1.5 text-sm transition-colors ${
filamentChoice === f.id
? 'bg-bambu-green/20 text-white'
: 'text-white hover:bg-bambu-dark-tertiary'
}`}
>
{f.name}
</button>
))}
</div>
))}
</div>
</div>
)}
</div>
{/* Flow Type and Nozzle Size - read-only when editing */}
<div className="grid grid-cols-2 gap-4">
<div>
{/* Flow Type and Nozzle Size - read-only when editing. Flow type
is hidden on models sold with a single nozzle variant (the
A-series), where the choice would be meaningless — same gate
the slicer applies via support_nozzle_volume(). */}
<div className={supportsFlowType ? 'grid grid-cols-2 gap-4' : ''}>
<div className={supportsFlowType ? '' : 'hidden'}>
<label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.flowType')}</label>
<select
value={nozzleType}
@@ -505,10 +604,10 @@ function KProfileModal({
setNozzleType(newNozzleType);
// Update profile name when flow type changes (for new profiles)
// Only auto-generate if name is empty - don't overwrite user input
if (!profile && filamentId && !name) {
const selectedFilament = knownFilaments.find(f => f.id === filamentId);
if (!profile && filamentChoice && !name) {
const selectedFilament = filamentPresets.find(f => f.id === filamentChoice);
if (selectedFilament) {
const flowLabel = newNozzleType === 'HS00' ? 'HF' : 'S';
const flowLabel = newNozzleType === HIGH_FLOW ? 'HF' : 'S';
setName(`${flowLabel} ${selectedFilament.name}`);
}
}
@@ -516,8 +615,8 @@ function KProfileModal({
disabled={!!profile}
className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
>
<option value="HH00">{t('kProfiles.modal.highFlow')}</option>
<option value="HS00">{t('kProfiles.modal.standard')}</option>
<option value={HIGH_FLOW}>{t('kProfiles.modal.highFlow')}</option>
<option value={STANDARD_FLOW}>{t('kProfiles.modal.standard')}</option>
</select>
</div>
<div>
@@ -772,13 +871,12 @@ export function KProfilesView() {
refetchOnMount: 'always', // Always refetch when component mounts
});
// Also fetch 0.4mm profiles for the filament dropdown (most filaments are calibrated for 0.4mm)
const { data: allProfiles } = useQuery({
queryKey: ['kprofiles', selectedPrinter, '0.4'],
queryFn: () => api.getKProfiles(selectedPrinter!, '0.4'),
enabled: !!selectedPrinter,
staleTime: 60000, // Cache for 1 minute
});
// A second fetch for 0.4mm profiles used to seed the Add-Profile filament
// dropdown. The dropdown is built from the filament preset tiers now
// (#2719), so the round trip bought nothing — and it fired concurrently
// with the fetch above whenever a different nozzle was selected, which is
// exactly the two-requests-in-flight case that made K-profile fetches time
// out (#1748).
// Fetch builtin filament names for accurate filament_id → name resolution
const { data: builtinFilaments } = useQuery({
@@ -794,6 +892,28 @@ export function KProfilesView() {
staleTime: 300000, // Cache for 5 minutes
});
// The other three filament tiers, so a printer with no K-profiles yet can
// still be given its first one (#2719). Each query stands alone and fails
// quietly: not being signed in to a cloud should thin the list, not break
// the page, and the builtin tier above guarantees it is never empty.
const { data: localPresets } = useQuery({
queryKey: ['localPresets'],
queryFn: () => api.getLocalPresets(),
retry: false,
});
const { data: orcaCloudList } = useQuery({
queryKey: ['orcaCloudProfilesForKProfiles'],
queryFn: () => api.orcaCloudListProfiles(),
retry: false,
});
const { data: cloudSettings } = useQuery({
queryKey: ['cloudSettings'],
queryFn: () => api.getCloudSettings(),
retry: false,
});
// Fetch K-profile notes (stored locally)
const {
data: notesData,
@@ -860,6 +980,19 @@ export function KProfilesView() {
}));
}, [builtinFilamentMap]);
// Every filament this install knows about, ranked in the app-wide order:
// imported presets, then Orca Cloud, then Bambu Cloud, then the hardcoded
// built-in table as the floor.
const filamentPresets = React.useMemo(
() => buildFilamentPresetOptions({
localPresets: localPresets?.filament,
orcaProfiles: orcaCloudList?.filament,
cloudSettings: cloudSettings?.filament,
builtinFilaments,
}),
[localPresets?.filament, orcaCloudList?.filament, cloudSettings?.filament, builtinFilaments]
);
// Resolve filament name: builtin table first, then extract from profile name
const resolveFilamentName = React.useCallback((profile: KProfile) => {
return builtinFilamentMap.get(profile.filament_id) || extractFilamentName(profile.name);
@@ -911,6 +1044,18 @@ export function KProfilesView() {
const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
const isDualNozzle = selectedPrinterData?.nozzle_count === 2;
// Whether this printer model is sold with both Standard and High Flow
// nozzles. Comes from the model, not from whether the payload happened to
// carry a nozzle_id — most printers omit that field entirely (#1748) while
// still offering both flows. Only the A-series has a single variant.
const supportsFlowType = selectedPrinterData?.supports_nozzle_flow_type ?? true;
// Don't strand the list behind a filter whose control just disappeared.
useEffect(() => {
if (!supportsFlowType) setFlowTypeFilter('all');
}, [supportsFlowType]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -1002,7 +1147,10 @@ export function KProfilesView() {
name: p.name,
k_value: parseFloat(p.k_value).toFixed(6),
filament_id: p.filament_id,
nozzle_id: p.nozzle_id || `HH00-${nozzleDiameter}`,
// An export from a printer that reports no nozzle_id carries
// none; fall back to Standard, the same default the slicer's
// parser uses for a missing field.
nozzle_id: p.nozzle_id || `${STANDARD_FLOW}-${nozzleDiameter}`,
nozzle_diameter: p.nozzle_diameter || nozzleDiameter,
extruder_id: p.extruder_id ?? 0,
slot_id: 0, // Always create new
@@ -1248,17 +1396,19 @@ export function KProfilesView() {
</select>
</div>
)}
<div className="w-32">
<select
value={flowTypeFilter}
onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="all">{t('kProfiles.allFlow')}</option>
<option value="hf">{t('kProfiles.hfOnly')}</option>
<option value="s">{t('kProfiles.sOnly')}</option>
</select>
</div>
{supportsFlowType && (
<div className="w-32">
<select
value={flowTypeFilter}
onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="all">{t('kProfiles.allFlow')}</option>
<option value="hf">{t('kProfiles.hfOnly')}</option>
<option value="s">{t('kProfiles.sOnly')}</option>
</select>
</div>
)}
<div className="w-32">
<select
value={sortOption}
@@ -1451,9 +1601,11 @@ export function KProfilesView() {
profile={editingProfile}
printerId={selectedPrinter}
nozzleDiameter={nozzleDiameter}
existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
existingProfiles={kprofiles?.profiles}
builtinFilaments={enrichedBuiltinFilaments}
filamentPresets={filamentPresets}
isDualNozzle={isDualNozzle}
supportsFlowType={supportsFlowType}
initialNote={note}
initialNoteKey={key}
onSaveNote={handleSaveNote}
@@ -1476,9 +1628,11 @@ export function KProfilesView() {
<KProfileModal
printerId={selectedPrinter}
nozzleDiameter={nozzleDiameter}
existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
existingProfiles={kprofiles?.profiles}
builtinFilaments={enrichedBuiltinFilaments}
filamentPresets={filamentPresets}
isDualNozzle={isDualNozzle}
supportsFlowType={supportsFlowType}
onSaveNote={handleSaveNote}
hasPermission={hasPermission}
onClose={() => {
@@ -1497,9 +1651,11 @@ export function KProfilesView() {
<KProfileModal
printerId={selectedPrinter}
nozzleDiameter={nozzleDiameter}
existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
existingProfiles={kprofiles?.profiles}
builtinFilaments={enrichedBuiltinFilaments}
filamentPresets={filamentPresets}
isDualNozzle={isDualNozzle}
supportsFlowType={supportsFlowType}
onSaveNote={handleSaveNote}
hasPermission={hasPermission}
// Pass profile data but without slot_id to create a new profile
@@ -0,0 +1,37 @@
import { useCallback, useEffect, useRef } from 'react';
/**
* setTimeout that cannot outlive the component that scheduled it.
*
* Modals here defer their own close by a second or more so the printer has
* time to process the command that was just sent. A plain setTimeout for that
* keeps a reference to setState and to the parent's onClose, and fires whether
* or not the modal is still mounted — closing an already-dismissed dialog, or
* throwing outright once the surrounding environment is gone ("window is not
* defined" when a test's DOM is torn down before the timer fires).
*
* Returns a schedule function. Scheduling again replaces any pending timer, and
* unmounting cancels it.
*/
export function useCancellableTimeout() {
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancel = useCallback(() => {
if (timer.current !== null) {
clearTimeout(timer.current);
timer.current = null;
}
}, []);
const schedule = useCallback((fn: () => void, ms: number) => {
cancel();
timer.current = setTimeout(() => {
timer.current = null;
fn();
}, ms);
}, [cancel]);
useEffect(() => cancel, [cancel]);
return { schedule, cancel };
}
+11 -1
View File
@@ -5034,7 +5034,15 @@ export default {
kValueHelp: 'Typischer Bereich: 0,01 - 0,06 für PLA, 0,02 - 0,10 für PETG',
filament: 'Filament',
selectFilament: 'Filament auswählen...',
noFilamentsHelp: 'Keine Filamente gefunden. Erstellen Sie zuerst ein K-Profil in Bambu Studio.',
source: {
local: 'Importiert',
orcaCloud: 'Orca Cloud',
bambuCloud: 'Bambu Cloud',
builtin: 'Integriert',
},
noFilamentsHelp: 'Keine Filamente verfügbar. Melde dich bei Bambu Cloud an oder importiere Presets unter Profile → Lokale Profile.',
searchFilaments: 'Filamente durchsuchen...',
noFilamentMatches: 'Kein Filament passt zu dieser Suche',
flowType: 'Flusstyp',
highFlow: 'Hoher Durchfluss',
standard: 'Standard',
@@ -5067,6 +5075,8 @@ export default {
profileSaved: 'K-Profil gespeichert',
profilesSaved: 'K-Profil auf {{count}} Extrudern gespeichert',
selectAtLeastOneExtruder: 'Bitte wählen Sie mindestens einen Extruder aus',
selectFilament: 'Bitte zuerst ein Filament auswählen',
filamentNotResolvable: 'Keine Bambu-Filament-ID für {{name}} — der Drucker kann dafür kein Profil speichern',
profileDeleted: 'K-Profil gelöscht',
profilesDeleted: '{{count}} Profile gelöscht',
exportedProfiles: '{{count}} Profile exportiert',
+11 -1
View File
@@ -5078,7 +5078,15 @@ export default {
kValueHelp: 'Typical range: 0.01 - 0.06 for PLA, 0.02 - 0.10 for PETG',
filament: 'Filament',
selectFilament: 'Select filament...',
noFilamentsHelp: 'No filaments found. Create a K-profile in Bambu Studio first.',
source: {
local: 'Imported',
orcaCloud: 'Orca Cloud',
bambuCloud: 'Bambu Cloud',
builtin: 'Built-in',
},
noFilamentsHelp: 'No filaments available. Sign in to Bambu Cloud, or import presets under Profiles → Local Profiles.',
searchFilaments: 'Search filaments...',
noFilamentMatches: 'No filament matches that search',
flowType: 'Flow Type',
highFlow: 'High Flow',
standard: 'Standard',
@@ -5111,6 +5119,8 @@ export default {
profileSaved: 'K-profile saved',
profilesSaved: 'K-profile saved to {{count}} extruders',
selectAtLeastOneExtruder: 'Please select at least one extruder',
selectFilament: 'Select a filament first',
filamentNotResolvable: 'No Bambu filament ID for {{name}} — the printer cannot store a profile for it',
profileDeleted: 'K-profile deleted',
profilesDeleted: 'Deleted {{count}} profiles',
exportedProfiles: 'Exported {{count}} profiles',
+11 -1
View File
@@ -5043,7 +5043,15 @@ export default {
kValueHelp: 'Rango típico: 0,01 - 0,06 para PLA, 0,02 - 0,10 para PETG',
filament: 'Filamento',
selectFilament: 'Seleccionar filamento...',
noFilamentsHelp: 'No se encontraron filamentos. Cree primero un perfil K en Bambu Studio.',
source: {
local: 'Importado',
orcaCloud: 'Orca Cloud',
bambuCloud: 'Bambu Cloud',
builtin: 'Integrado',
},
noFilamentsHelp: 'No hay filamentos disponibles. Inicia sesión en Bambu Cloud o importa ajustes en Perfiles → Perfiles locales.',
searchFilaments: 'Buscar filamentos...',
noFilamentMatches: 'Ningún filamento coincide con esa búsqueda',
flowType: 'Tipo de flujo',
highFlow: 'Flujo alto',
standard: 'Estándar',
@@ -5076,6 +5084,8 @@ export default {
profileSaved: 'Perfil K guardado',
profilesSaved: 'Perfil K guardado en {{count}} extrusores',
selectAtLeastOneExtruder: 'Seleccione al menos un extrusor',
selectFilament: 'Selecciona primero un filamento',
filamentNotResolvable: 'No hay ID de filamento Bambu para {{name}}: la impresora no puede guardar un perfil',
profileDeleted: 'Perfil K eliminado',
profilesDeleted: 'Se eliminaron {{count}} perfiles',
exportedProfiles: 'Se exportaron {{count}} perfiles',
+11 -1
View File
@@ -5024,7 +5024,15 @@ export default {
kValueHelp: 'Plage type : 0.01-0.06 (PLA), 0.02-0.10 (PETG)',
filament: 'Filament',
selectFilament: 'Choisir filament...',
noFilamentsHelp: 'Créez d\'abord un profil dans Bambu Studio.',
source: {
local: 'Importé',
orcaCloud: 'Orca Cloud',
bambuCloud: 'Bambu Cloud',
builtin: 'Inclus',
},
noFilamentsHelp: 'Aucun filament disponible. Connectez-vous à Bambu Cloud ou importez des préréglages dans Profils → Profils locaux.',
searchFilaments: 'Rechercher des filaments...',
noFilamentMatches: 'Aucun filament ne correspond à cette recherche',
flowType: 'Type de débit',
highFlow: 'Haut Débit (HF)',
standard: 'Standard',
@@ -5057,6 +5065,8 @@ export default {
profileSaved: 'Profil K enregistré',
profilesSaved: 'Profil K enregistré sur {{count}} extrudeur(s)',
selectAtLeastOneExtruder: 'Sélectionnez un extrudeur',
selectFilament: 'Sélectionnez d’abord un filament',
filamentNotResolvable: 'Aucun identifiant de filament Bambu pour {{name}} — l’imprimante ne peut pas enregistrer de profil',
profileDeleted: 'Profil K supprimé',
profilesDeleted: '{{count}} profils supprimés',
exportedProfiles: '{{count}} profils exportés',
+11 -1
View File
@@ -5023,7 +5023,15 @@ export default {
kValueHelp: 'Intervallo tipico: 0.01 - 0.06 per PLA, 0.02 - 0.10 per PETG',
filament: 'Filamento',
selectFilament: 'Seleziona filamento...',
noFilamentsHelp: 'Nessun filamento trovato. Crea prima un K-profile in Bambu Studio.',
source: {
local: 'Importato',
orcaCloud: 'Orca Cloud',
bambuCloud: 'Bambu Cloud',
builtin: 'Integrato',
},
noFilamentsHelp: 'Nessun filamento disponibile. Accedi a Bambu Cloud o importa i preset da Profili → Profili locali.',
searchFilaments: 'Cerca filamenti...',
noFilamentMatches: 'Nessun filamento corrisponde alla ricerca',
flowType: 'Tipo flow',
highFlow: 'Alto flusso',
standard: 'Standard',
@@ -5056,6 +5064,8 @@ export default {
profileSaved: 'K-profile salvato',
profilesSaved: 'K-profile salvato su {{count}} estrusori',
selectAtLeastOneExtruder: 'Seleziona almeno un estrusore',
selectFilament: 'Seleziona prima un filamento',
filamentNotResolvable: 'Nessun ID filamento Bambu per {{name}}: la stampante non può salvare un profilo',
profileDeleted: 'K-profile eliminato',
profilesDeleted: 'Eliminati {{count}} profili',
exportedProfiles: 'Esportati {{count}} profili',
+11 -1
View File
@@ -5035,7 +5035,15 @@ export default {
kValueHelp: '一般的な範囲: PLA 0.01〜0.06、PETG 0.02〜0.10',
filament: 'フィラメント',
selectFilament: 'フィラメントを選択...',
noFilamentsHelp: 'フィラメントが見つかりません。Bambu Studioでまずプロファイルを作成してください。',
source: {
local: 'インポート済み',
orcaCloud: 'Orca Cloud',
bambuCloud: 'Bambu Cloud',
builtin: '内蔵',
},
noFilamentsHelp: '利用できるフィラメントがありません。Bambu Cloud にログインするか、プロファイル → ローカルプロファイル でプリセットをインポートしてください。',
searchFilaments: 'フィラメントを検索...',
noFilamentMatches: '検索に一致するフィラメントはありません',
flowType: 'フロータイプ',
highFlow: 'ハイフロー',
standard: 'スタンダード',
@@ -5068,6 +5076,8 @@ export default {
profileSaved: 'Kプロファイルを保存しました',
profilesSaved: 'Kプロファイルを{{count}}台のエクストルーダーに保存しました',
selectAtLeastOneExtruder: 'エクストルーダーを1つ以上選択してください',
selectFilament: '先にフィラメントを選択してください',
filamentNotResolvable: '{{name}} に対応する Bambu フィラメント ID がないため、プリンターはプロファイルを保存できません',
profileDeleted: 'Kプロファイルを削除しました',
profilesDeleted: '{{count}}件のプロファイルを削除しました',
exportedProfiles: '{{count}}件のプロファイルをエクスポートしました',
+11 -1
View File
@@ -4778,7 +4778,15 @@ export default {
kValueHelp: '일반 범위: PLA 0.01~0.06, PETG 0.02~0.10',
filament: '필라멘트',
selectFilament: '필라멘트 선택...',
noFilamentsHelp: '필라멘트를 찾을 수 없습니다. 먼저 Bambu Studio에서 K-프로필을 만드세요.',
source: {
local: '가져온 것',
orcaCloud: 'Orca Cloud',
bambuCloud: 'Bambu Cloud',
builtin: '기본 제공',
},
noFilamentsHelp: '사용할 수 있는 필라먼트가 없습니다. Bambu Cloud에 로그인하거나 프로파일 → 로컬 프로파일에서 프리셋을 가져오세요.',
searchFilaments: '필라먼트 검색...',
noFilamentMatches: '검색과 일치하는 필라먼트가 없습니다',
flowType: '유량 유형',
highFlow: '고유량',
standard: '표준',
@@ -4808,6 +4816,8 @@ export default {
profileSaved: 'K-프로필 저장됨',
profilesSaved: '{{count}}개 압출기에 K-프로필 저장됨',
selectAtLeastOneExtruder: '적어도 하나의 압출기를 선택해 주세요',
selectFilament: '먼저 필라먼트를 선택하세요',
filamentNotResolvable: '{{name}}에 해당하는 Bambu 필라먼트 ID가 없어 프린터가 프로파일을 저장할 수 없습니다',
profileDeleted: 'K-프로필 삭제됨',
profilesDeleted: '{{count}}개 프로필 삭제됨',
exportedProfiles: '{{count}}개 프로필 내보냄',
+11 -1
View File
@@ -5023,7 +5023,15 @@ export default {
kValueHelp: 'Faixa típica: 0.01 - 0.06 para PLA, 0.02 - 0.10 para PETG',
filament: 'Filamento',
selectFilament: 'Selecionar filamento...',
noFilamentsHelp: 'Nenhum filamento encontrado. Crie um K-profile no Bambu Studio primeiro.',
source: {
local: 'Importado',
orcaCloud: 'Orca Cloud',
bambuCloud: 'Bambu Cloud',
builtin: 'Integrado',
},
noFilamentsHelp: 'Nenhum filamento disponível. Entre na Bambu Cloud ou importe predefinições em Perfis → Perfis Locais.',
searchFilaments: 'Buscar filamentos...',
noFilamentMatches: 'Nenhum filamento corresponde a essa busca',
flowType: 'Tipo de Fluxo',
highFlow: 'Alto Fluxo',
standard: 'Padrão',
@@ -5056,6 +5064,8 @@ export default {
profileSaved: 'K-profile salvo',
profilesSaved: 'K-profile salvo em {{count}} extrusores',
selectAtLeastOneExtruder: 'Por favor, selecione pelo menos um extrusor',
selectFilament: 'Selecione um filamento primeiro',
filamentNotResolvable: 'Sem ID de filamento Bambu para {{name}} — a impressora não consegue armazenar um perfil',
profileDeleted: 'K-profile excluído',
profilesDeleted: '{{count}} perfis excluídos',
exportedProfiles: '{{count}} perfis exportados',
+11 -1
View File
@@ -4766,7 +4766,15 @@ export default {
kValueHelp: "Типичный диапазон: 0,01–0,06 для PLA, 0,02–0,10 для PETG",
filament: "Филамент",
selectFilament: "Выберите филамент...",
noFilamentsHelp: "Филаменты не найдены. Сначала создайте K-профиль в Bambu Studio.",
source: {
local: "Импортированные",
orcaCloud: "Orca Cloud",
bambuCloud: "Bambu Cloud",
builtin: "Встроенный",
},
noFilamentsHelp: "Нет доступных филаментов. Войдите в Bambu Cloud или импортируйте пресеты в разделе Профили → Локальные профили.",
searchFilaments: "Поиск филаментов...",
noFilamentMatches: "Нет филаментов, соответствующих запросу",
flowType: "Тип потока",
highFlow: "Высокопоточный",
standard: "Стандартный",
@@ -4796,6 +4804,8 @@ export default {
profileSaved: "K-профиль сохранён",
profilesSaved: "K-профиль сохранён для {{count}} экструдеров",
selectAtLeastOneExtruder: "Выберите хотя бы один экструдер",
selectFilament: "Сначала выберите филамент",
filamentNotResolvable: "Нет идентификатора филамента Bambu для {{name}} — принтер не сможет сохранить профиль",
profileDeleted: "K-профиль удалён",
profilesDeleted: "Удалено профилей: {{count}}",
exportedProfiles: "Экспортировано профилей: {{count}}",
+11 -1
View File
@@ -5003,7 +5003,15 @@ export default {
kValueHelp: 'Tipik aralık: PLA için 0.01 - 0.06, PETG için 0.02 - 0.10',
filament: 'Filament',
selectFilament: 'Filament seç...',
noFilamentsHelp: 'Filament bulunamadı. Önce Bambu Studio\'da bir K-profili oluşturun.',
source: {
local: 'İçe aktarılmış',
orcaCloud: 'Orca Cloud',
bambuCloud: 'Bambu Cloud',
builtin: 'Yerleşik',
},
noFilamentsHelp: 'Kullanılabilir filament yok. Bambu Cloud’a giriş yapın veya Profiller → Yerel Profiller altından hızır ayarları içe aktarın.',
searchFilaments: 'Filament ara...',
noFilamentMatches: 'Bu aramayla eşleşen filament yok',
flowType: 'Akış Türü',
highFlow: 'Yüksek Akış',
standard: 'Standart',
@@ -5033,6 +5041,8 @@ export default {
profileSaved: 'K-profili kaydedildi',
profilesSaved: '{{count}} ekstrüdere K-profili kaydedildi',
selectAtLeastOneExtruder: 'Lütfen en az bir ekstrüder seçin',
selectFilament: 'Önce bir filament seçin',
filamentNotResolvable: '{{name}} için Bambu filament kimliği yok — yazıcı bunun için profil saklayamaz',
profileDeleted: 'K-profili silindi',
profilesDeleted: '{{count}} profil silindi',
exportedProfiles: '{{count}} profil dışa aktarıldı',
+11 -1
View File
@@ -5078,7 +5078,15 @@ export default {
kValueHelp: "Типовий діапазон: 0,01–0,06 для PLA, 0,02–0,10 для PETG",
filament: "Філамент",
selectFilament: "Виберіть філамент...",
noFilamentsHelp: "Філаменти не знайдено. Спочатку створіть K-профіль у Bambu Studio.",
source: {
local: "Імпортовані",
orcaCloud: "Orca Cloud",
bambuCloud: "Bambu Cloud",
builtin: "Вбудований",
},
noFilamentsHelp: "Немає доступних філаментів. Увійдіть у Bambu Cloud або імпортуйте пресети в розділі Профілі → Локальні профілі.",
searchFilaments: "Пошук філаментів...",
noFilamentMatches: "Немає філаментів, що відповідають запиту",
flowType: "Тип потоку",
highFlow: "Сопло з високим потоком",
standard: "Стандартний",
@@ -5111,6 +5119,8 @@ export default {
profileSaved: "K-профіль збережено",
profilesSaved: "K-профіль збережено в екструдери {{count}}.",
selectAtLeastOneExtruder: "Виберіть принаймні один екструдер",
selectFilament: "Спочатку виберіть філамент",
filamentNotResolvable: "Немає ідентифікатора філаменту Bambu для {{name}} — принтер не зможе зберегти профіль",
profileDeleted: "K-профіль видалено",
profilesDeleted: "Видалені профілі {{count}}.",
exportedProfiles: "Експортовані профілі {{count}}.",
+11 -1
View File
@@ -5023,7 +5023,15 @@ export default {
kValueHelp: '典型范围:PLA 0.01 - 0.06,PETG 0.02 - 0.10',
filament: '耗材',
selectFilament: '选择耗材...',
noFilamentsHelp: '未找到耗材。请先在 Bambu Studio 中创建 K 值配置。',
source: {
local: '已导入',
orcaCloud: 'Orca Cloud',
bambuCloud: 'Bambu Cloud',
builtin: '内置',
},
noFilamentsHelp: '没有可用的耗材。请登录 Bambu Cloud,或在“配置 → 本地配置”中导入预设。',
searchFilaments: '搜索耗材...',
noFilamentMatches: '没有符合搜索条件的耗材',
flowType: '流量类型',
highFlow: '高流量',
standard: '标准',
@@ -5056,6 +5064,8 @@ export default {
profileSaved: 'K 值配置已保存',
profilesSaved: 'K 值配置已保存到 {{count}} 个挤出机',
selectAtLeastOneExtruder: '请至少选择一个挤出机',
selectFilament: '请先选择耗材',
filamentNotResolvable: '没有与 {{name}} 对应的 Bambu 耗材 ID,打印机无法保存该配置',
profileDeleted: 'K 值配置已删除',
profilesDeleted: '已删除 {{count}} 个配置',
exportedProfiles: '已导出 {{count}} 个配置',
+11 -1
View File
@@ -5023,7 +5023,15 @@ export default {
kValueHelp: '典型範圍:PLA 0.01 - 0.06,PETG 0.02 - 0.10',
filament: '耗材',
selectFilament: '選擇耗材...',
noFilamentsHelp: '未找到耗材。請先在 Bambu Studio 中建立 K 值設定。',
source: {
local: '已匯入',
orcaCloud: 'Orca Cloud',
bambuCloud: 'Bambu Cloud',
builtin: '內建',
},
noFilamentsHelp: '沒有可用的耗材。請登入 Bambu Cloud,或在「設定檔 → 本地設定檔」中匯入預設。',
searchFilaments: '搜尋耗材...',
noFilamentMatches: '沒有符合搜尋條件的耗材',
flowType: '流量類型',
highFlow: '高流量',
standard: '標準',
@@ -5056,6 +5064,8 @@ export default {
profileSaved: 'K 值設定已儲存',
profilesSaved: 'K 值設定已儲存到 {{count}} 個擠出機',
selectAtLeastOneExtruder: '請至少選擇一個擠出機',
selectFilament: '請先選擇耗材',
filamentNotResolvable: '沒有與 {{name}} 對應的 Bambu 耗材 ID,印表機無法儲存該設定檔',
profileDeleted: 'K 值設定已刪除',
profilesDeleted: '已刪除 {{count}} 個設定',
exportedProfiles: '已匯出 {{count}} 個設定',
+120 -78
View File
@@ -882,6 +882,16 @@ export function SettingsPage() {
const pendingGcodeSnippetsRef = useRef<string | null>(null);
const isSavingRef = useRef(false);
const isInitialLoadRef = useRef(true);
// #2716: the last server snapshot this page reconciled with. It is what
// makes "the user edited this field" a well-defined question: a field where
// localSettings still equals the baseline has not been touched since that
// reconcile, so a newer server value can be taken instead of the page's stale
// copy being written back over it. Before this the debounced save diffed
// against the live ['settings'] cache, which made a value changed on the
// server -- another tab, another user, a backup restore, a refetch driven by
// any of the ~30 other observers of the key -- indistinguishable from an edit,
// and reverted it a few hundred ms later with no user interaction at all.
const serverBaselineRef = useRef<AppSettings | null>(null);
// Sync local state when settings load
useEffect(() => {
@@ -891,6 +901,9 @@ export function SettingsPage() {
...settings,
external_url: settings.external_url || window.location.origin,
};
// The baseline is the raw server row, not this adjusted copy: a detected
// external_url has to read as a local change so it still gets persisted.
serverBaselineRef.current = settings;
setLocalSettings(settingsWithExternalUrl);
// Mark initial load complete after a short delay
setTimeout(() => {
@@ -899,9 +912,37 @@ export function SettingsPage() {
}
}, [settings, localSettings]);
// #2716: reconcile a moved server snapshot into the local copy. A field the
// user has not touched since the last reconcile takes the server's value; a
// field they have edited keeps theirs and is saved over it by the debounced
// effect below, so the newer of the two writes wins either way. Declared
// before that effect so the baseline has already moved by the time it
// computes its diff in the same commit.
useEffect(() => {
const baseline = serverBaselineRef.current;
if (!settings || !localSettings || !baseline || settings === baseline) {
return;
}
const adopted: Record<string, unknown> = {};
for (const key of Object.keys(settings) as (keyof AppSettings)[]) {
if (settings[key] !== baseline[key] && localSettings[key] === baseline[key]) {
adopted[key] = settings[key];
}
}
serverBaselineRef.current = settings;
if (Object.keys(adopted).length > 0) {
setLocalSettings(prev => (prev ? { ...prev, ...(adopted as Partial<AppSettings>) } : prev));
}
}, [settings, localSettings]);
const updateMutation = useMutation({
mutationFn: api.updateSettings,
onSuccess: (data) => {
// #2716: the row we just saved becomes the snapshot to diff against.
// The setQueryData below would normally get the effect above to do this,
// but only if react-query hands back a new object; setting it here means
// the baseline never lags behind a save regardless.
serverBaselineRef.current = data;
queryClient.setQueryData(['settings'], data);
// Don't call setLocalSettings(data) here — it would overwrite in-progress
// user input (e.g. typing a hostname) with the stale saved snapshot,
@@ -942,7 +983,8 @@ export function SettingsPage() {
// Debounced auto-save when localSettings change
useEffect(() => {
// Skip if initial load or no settings
if (isInitialLoadRef.current || !localSettings || !settings) {
const baseline = serverBaselineRef.current;
if (isInitialLoadRef.current || !localSettings || !settings || !baseline) {
return;
}
@@ -956,83 +998,83 @@ export function SettingsPage() {
// Check if there are actual changes
const hasChanges =
settings.auto_archive !== localSettings.auto_archive ||
settings.save_thumbnails !== localSettings.save_thumbnails ||
settings.capture_finish_photo !== localSettings.capture_finish_photo ||
(settings.finish_photo_restore_plate ?? true) !== (localSettings.finish_photo_restore_plate ?? true) ||
settings.default_filament_cost !== localSettings.default_filament_cost ||
settings.currency !== localSettings.currency ||
settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
settings.energy_tracking_mode !== localSettings.energy_tracking_mode ||
settings.check_updates !== localSettings.check_updates ||
(settings.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
(settings.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) ||
(settings.local_login_enabled ?? true) !== (localSettings.local_login_enabled ?? true) ||
settings.notification_language !== localSettings.notification_language ||
(settings.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
settings.ams_humidity_good !== localSettings.ams_humidity_good ||
settings.ams_humidity_fair !== localSettings.ams_humidity_fair ||
settings.ams_temp_good !== localSettings.ams_temp_good ||
settings.ams_temp_fair !== localSettings.ams_temp_fair ||
settings.ams_history_retention_days !== localSettings.ams_history_retention_days ||
settings.disable_filament_warnings !== localSettings.disable_filament_warnings ||
settings.prefer_lowest_filament !== localSettings.prefer_lowest_filament ||
(settings.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) ||
(settings.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) ||
(settings.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) ||
(settings.print_drying_enabled ?? false) !== (localSettings.print_drying_enabled ?? false) ||
(settings.drying_presets ?? '') !== (localSettings.drying_presets ?? '') ||
(settings.ams_humidity_thresholds ?? '') !== (localSettings.ams_humidity_thresholds ?? '') ||
settings.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded ||
settings.date_format !== localSettings.date_format ||
settings.time_format !== localSettings.time_format ||
settings.default_printer_id !== localSettings.default_printer_id ||
settings.ftp_retry_enabled !== localSettings.ftp_retry_enabled ||
settings.ftp_retry_count !== localSettings.ftp_retry_count ||
settings.ftp_retry_delay !== localSettings.ftp_retry_delay ||
settings.ftp_timeout !== localSettings.ftp_timeout ||
settings.mqtt_enabled !== localSettings.mqtt_enabled ||
settings.mqtt_broker !== localSettings.mqtt_broker ||
settings.mqtt_port !== localSettings.mqtt_port ||
settings.mqtt_username !== localSettings.mqtt_username ||
settings.mqtt_password !== localSettings.mqtt_password ||
settings.mqtt_topic_prefix !== localSettings.mqtt_topic_prefix ||
settings.mqtt_use_tls !== localSettings.mqtt_use_tls ||
settings.external_url !== localSettings.external_url ||
settings.ha_enabled !== localSettings.ha_enabled ||
settings.ha_url !== localSettings.ha_url ||
settings.ha_token !== localSettings.ha_token ||
(settings.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
Number(settings.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
(settings.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') ||
(settings.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
(settings.open_in_slicer ?? null) !== (localSettings.open_in_slicer ?? null) ||
(settings.use_slicer_api ?? false) !== (localSettings.use_slicer_api ?? false) ||
(settings.orcaslicer_api_url ?? '') !== (localSettings.orcaslicer_api_url ?? '') ||
(settings.slicer_stall_timeout_minutes ?? 15) !== (localSettings.slicer_stall_timeout_minutes ?? 15) ||
(settings.bambu_studio_api_url ?? '') !== (localSettings.bambu_studio_api_url ?? '') ||
settings.prometheus_enabled !== localSettings.prometheus_enabled ||
settings.prometheus_token !== localSettings.prometheus_token ||
(settings.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true) ||
(settings.default_bed_levelling ?? 'auto') !== (localSettings.default_bed_levelling ?? 'auto') ||
(settings.default_flow_cali ?? 'auto') !== (localSettings.default_flow_cali ?? 'auto') ||
(settings.default_vibration_cali ?? true) !== (localSettings.default_vibration_cali ?? true) ||
(settings.default_layer_inspect ?? false) !== (localSettings.default_layer_inspect ?? false) ||
(settings.default_timelapse ?? false) !== (localSettings.default_timelapse ?? false) ||
(settings.default_nozzle_offset_cali ?? 'auto') !== (localSettings.default_nozzle_offset_cali ?? 'auto') ||
(settings.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
(settings.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) ||
(settings.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false) ||
(settings.queue_max_concurrent_uploads ?? 4) !== (localSettings.queue_max_concurrent_uploads ?? 4) ||
(settings.preheat_enabled ?? false) !== (localSettings.preheat_enabled ?? false) ||
(settings.preheat_filament_targets ?? '') !== (localSettings.preheat_filament_targets ?? '') ||
(settings.preheat_max_wait_seconds ?? 900) !== (localSettings.preheat_max_wait_seconds ?? 900) ||
(settings.preheat_soak_seconds ?? 300) !== (localSettings.preheat_soak_seconds ?? 300) ||
(settings.nozzle_temp_presets ?? '') !== (localSettings.nozzle_temp_presets ?? '') ||
(settings.bed_temp_presets ?? '') !== (localSettings.bed_temp_presets ?? '') ||
(settings.chamber_temp_presets ?? '') !== (localSettings.chamber_temp_presets ?? '') ||
(settings.fan_speed_presets ?? '') !== (localSettings.fan_speed_presets ?? '') ||
(settings.session_max_hours ?? 24) !== (localSettings.session_max_hours ?? 24);
baseline.auto_archive !== localSettings.auto_archive ||
baseline.save_thumbnails !== localSettings.save_thumbnails ||
baseline.capture_finish_photo !== localSettings.capture_finish_photo ||
(baseline.finish_photo_restore_plate ?? true) !== (localSettings.finish_photo_restore_plate ?? true) ||
baseline.default_filament_cost !== localSettings.default_filament_cost ||
baseline.currency !== localSettings.currency ||
baseline.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
baseline.energy_tracking_mode !== localSettings.energy_tracking_mode ||
baseline.check_updates !== localSettings.check_updates ||
(baseline.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
(baseline.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) ||
(baseline.local_login_enabled ?? true) !== (localSettings.local_login_enabled ?? true) ||
baseline.notification_language !== localSettings.notification_language ||
(baseline.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
baseline.ams_humidity_good !== localSettings.ams_humidity_good ||
baseline.ams_humidity_fair !== localSettings.ams_humidity_fair ||
baseline.ams_temp_good !== localSettings.ams_temp_good ||
baseline.ams_temp_fair !== localSettings.ams_temp_fair ||
baseline.ams_history_retention_days !== localSettings.ams_history_retention_days ||
baseline.disable_filament_warnings !== localSettings.disable_filament_warnings ||
baseline.prefer_lowest_filament !== localSettings.prefer_lowest_filament ||
(baseline.queue_drying_enabled ?? false) !== (localSettings.queue_drying_enabled ?? false) ||
(baseline.queue_drying_block ?? false) !== (localSettings.queue_drying_block ?? false) ||
(baseline.ambient_drying_enabled ?? false) !== (localSettings.ambient_drying_enabled ?? false) ||
(baseline.print_drying_enabled ?? false) !== (localSettings.print_drying_enabled ?? false) ||
(baseline.drying_presets ?? '') !== (localSettings.drying_presets ?? '') ||
(baseline.ams_humidity_thresholds ?? '') !== (localSettings.ams_humidity_thresholds ?? '') ||
baseline.per_printer_mapping_expanded !== localSettings.per_printer_mapping_expanded ||
baseline.date_format !== localSettings.date_format ||
baseline.time_format !== localSettings.time_format ||
baseline.default_printer_id !== localSettings.default_printer_id ||
baseline.ftp_retry_enabled !== localSettings.ftp_retry_enabled ||
baseline.ftp_retry_count !== localSettings.ftp_retry_count ||
baseline.ftp_retry_delay !== localSettings.ftp_retry_delay ||
baseline.ftp_timeout !== localSettings.ftp_timeout ||
baseline.mqtt_enabled !== localSettings.mqtt_enabled ||
baseline.mqtt_broker !== localSettings.mqtt_broker ||
baseline.mqtt_port !== localSettings.mqtt_port ||
baseline.mqtt_username !== localSettings.mqtt_username ||
baseline.mqtt_password !== localSettings.mqtt_password ||
baseline.mqtt_topic_prefix !== localSettings.mqtt_topic_prefix ||
baseline.mqtt_use_tls !== localSettings.mqtt_use_tls ||
baseline.external_url !== localSettings.external_url ||
baseline.ha_enabled !== localSettings.ha_enabled ||
baseline.ha_url !== localSettings.ha_url ||
baseline.ha_token !== localSettings.ha_token ||
(baseline.library_archive_mode ?? 'ask') !== (localSettings.library_archive_mode ?? 'ask') ||
Number(baseline.library_disk_warning_gb ?? 5) !== Number(localSettings.library_disk_warning_gb ?? 5) ||
(baseline.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') ||
(baseline.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
(baseline.open_in_slicer ?? null) !== (localSettings.open_in_slicer ?? null) ||
(baseline.use_slicer_api ?? false) !== (localSettings.use_slicer_api ?? false) ||
(baseline.orcaslicer_api_url ?? '') !== (localSettings.orcaslicer_api_url ?? '') ||
(baseline.slicer_stall_timeout_minutes ?? 15) !== (localSettings.slicer_stall_timeout_minutes ?? 15) ||
(baseline.bambu_studio_api_url ?? '') !== (localSettings.bambu_studio_api_url ?? '') ||
baseline.prometheus_enabled !== localSettings.prometheus_enabled ||
baseline.prometheus_token !== localSettings.prometheus_token ||
(baseline.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true) ||
(baseline.default_bed_levelling ?? 'auto') !== (localSettings.default_bed_levelling ?? 'auto') ||
(baseline.default_flow_cali ?? 'auto') !== (localSettings.default_flow_cali ?? 'auto') ||
(baseline.default_vibration_cali ?? true) !== (localSettings.default_vibration_cali ?? true) ||
(baseline.default_layer_inspect ?? false) !== (localSettings.default_layer_inspect ?? false) ||
(baseline.default_timelapse ?? false) !== (localSettings.default_timelapse ?? false) ||
(baseline.default_nozzle_offset_cali ?? 'auto') !== (localSettings.default_nozzle_offset_cali ?? 'auto') ||
(baseline.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
(baseline.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5) ||
(baseline.require_plate_clear ?? false) !== (localSettings.require_plate_clear ?? false) ||
(baseline.queue_max_concurrent_uploads ?? 4) !== (localSettings.queue_max_concurrent_uploads ?? 4) ||
(baseline.preheat_enabled ?? false) !== (localSettings.preheat_enabled ?? false) ||
(baseline.preheat_filament_targets ?? '') !== (localSettings.preheat_filament_targets ?? '') ||
(baseline.preheat_max_wait_seconds ?? 900) !== (localSettings.preheat_max_wait_seconds ?? 900) ||
(baseline.preheat_soak_seconds ?? 300) !== (localSettings.preheat_soak_seconds ?? 300) ||
(baseline.nozzle_temp_presets ?? '') !== (localSettings.nozzle_temp_presets ?? '') ||
(baseline.bed_temp_presets ?? '') !== (localSettings.bed_temp_presets ?? '') ||
(baseline.chamber_temp_presets ?? '') !== (localSettings.chamber_temp_presets ?? '') ||
(baseline.fan_speed_presets ?? '') !== (localSettings.fan_speed_presets ?? '') ||
(baseline.session_max_hours ?? 24) !== (localSettings.session_max_hours ?? 24);
if (!hasChanges) {
return;
+247
View File
@@ -0,0 +1,247 @@
// Tiered filament-preset list, shared by every picker that has to offer "all
// the filaments this install knows about".
//
// Lookup order is fixed across the app: local imported > Orca Cloud > Bambu
// Cloud > hardcoded built-in table. It mirrors ConfigureAmsSlotModal's picker
// and SliceModal's tier groups, so a filament the user sees in one place is
// named and ranked the same way in the others.
//
// The built-in table is the floor, not an equal source: it is a static list
// compiled into the backend, so it is the only tier that can never be empty
// and the only one that works with no cloud account and nothing imported.
import type { BuiltinFilament, LocalPreset, OrcaProfileMeta, SlicerSetting } from '../api/client';
import { parsePresetName, toFilamentId } from '../components/spool-form/utils';
export type FilamentPresetSource = 'local' | 'orca_cloud' | 'cloud' | 'builtin';
export interface FilamentPresetOption {
/** Opaque, source-prefixed handle: ``local_12`` / ``orca_<uuid>`` / a Bambu
* cloud setting_id / ``builtin_GFA00``. Prefixes match the convention
* ConfigureAmsSlotModal already uses so the two can share resolvers. */
id: string;
name: string;
source: FilamentPresetSource;
/** The Bambu filament id this preset resolves to, when it is derivable
* without a network round trip. Empty for Bambu Cloud *user* presets, whose
* real filament_id only exists in the cloud detail — see
* resolveFilamentId. */
filamentId: string;
/** Material as the preset itself declares it, used to derive a generic
* filament id for tiers that carry no Bambu id of their own. */
filamentType: string;
}
export interface FilamentPresetSources {
localPresets?: LocalPreset[];
orcaProfiles?: OrcaProfileMeta[];
cloudSettings?: SlicerSetting[];
builtinFilaments?: BuiltinFilament[];
}
/** Generic Bambu filament ids by material. Local and Orca Cloud presets carry
* no Bambu filament id, but the printer's calibration table is indexed by
* one, so the closest generic is what a calibration for such a preset has to
* be filed under. Same table and same fallback chain as the AMS slot
* configure flow — the two must agree or a profile created here won't match
* the slot configured there. */
const GENERIC_FILAMENT_IDS: Record<string, string> = {
'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',
};
/** Resolve a material string to a generic Bambu filament id, trying the exact
* spelling before progressively stripping the suffixes slicer presets add
* ("-CF", "+", " HF"). Returns '' when nothing matches, which callers must
* treat as "not calibratable" rather than substituting a default — filing a
* calibration under the wrong material is worse than refusing. */
export function genericFilamentIdForMaterial(material: string | null | undefined): string {
const m = (material || '').toUpperCase().trim();
if (!m) return '';
return GENERIC_FILAMENT_IDS[m]
|| GENERIC_FILAMENT_IDS[m.replace(/[-\s]?CF$/, '')]
|| GENERIC_FILAMENT_IDS[m.replace(/\+$/, '')]
|| GENERIC_FILAMENT_IDS[m.split(/[-\s]/)[0]]
|| '';
}
/** Strip the printer/nozzle suffix and the "# " custom-preset marker a preset
* name may carry, e.g. "Elegoo PLA+ @BBL X1C 0.4 nozzle" → "Elegoo PLA+". */
export function presetDisplayName(name: string): string {
const withoutSuffix = name.replace(/@.+$/, '').trim();
return withoutSuffix.startsWith('# ') ? withoutSuffix.slice(2).trim() : withoutSuffix;
}
const SOURCE_ORDER: Record<FilamentPresetSource, number> = {
local: 0,
orca_cloud: 1,
cloud: 2,
builtin: 3,
};
/**
* Merge every filament source into one ranked list.
*
* Deduplication is deliberately asymmetric, because "the same name in two
* tiers" means different things depending on which tiers:
*
* - *Within* a tier, by resolved filament id or display name. This is what
* collapses the per-printer-model copies a cloud account carries —
* "Bambu PLA Basic @BBL X1C", "@BBL P1S", "@BBL A1" are one name once the
* suffix is stripped — and repeated imports of one filament for several
* printers.
*
* - *Across* tiers, by id only. Two entries carrying the same id really are
* one record reached by two routes; two entries merely sharing a name are
* not. Imported presets and an Orca Cloud library overlap heavily by name
* (they are usually the same profiles, synced), and suppressing one for the
* other empties a tier the user curated on purpose. The heading says where
* each came from, which is the point of having tiers at all.
*
* - *Into the built-in tier*, by name as well as by id. That tier is a static
* table of the same Bambu catalogue every other source also ships, so
* without a name check it echoes back everything above it. It exists to
* guarantee the list is never empty, not to be a fourth copy.
*/
export function buildFilamentPresetOptions(sources: FilamentPresetSources): FilamentPresetOption[] {
const { localPresets, orcaProfiles, cloudSettings, builtinFilaments } = sources;
const options: FilamentPresetOption[] = [];
const nameKey = (name: string) => name.trim().toLowerCase();
// Ids seen anywhere: a cloud setting_id, an Orca profile id, a resolved
// filament id. Shared across tiers — an id collision is true identity.
const claimedIds = new Set<string>();
// Names seen, scoped to one tier, so two tiers can each list "Elegoo PLA+".
const namesInTier = new Set<string>();
// Every name any real source offered, consulted only by the built-in tier.
const namesOffered = new Set<string>();
const take = (source: FilamentPresetSource, name: string, ...ids: (string | undefined)[]): boolean => {
const usableIds = ids.filter((k): k is string => !!k);
if (usableIds.some(k => claimedIds.has(k))) return false;
const scoped = `${source}|${nameKey(name)}`;
if (namesInTier.has(scoped)) return false;
usableIds.forEach(k => claimedIds.add(k));
namesInTier.add(scoped);
namesOffered.add(nameKey(name));
return true;
};
// 1. Local imported presets. filament_id lives in the preset's setting JSON,
// which the list endpoint doesn't return, so the generic material id is what
// we can offer without a per-preset detail fetch.
for (const lp of localPresets ?? []) {
const name = presetDisplayName(lp.name);
const material = lp.filament_type || parsePresetName(name).material;
// No id is claimed here: the generic id an import maps to is shared by
// every filament of that material, so claiming it would let the first
// imported PLA swallow every other PLA in the list.
if (!take('local', name)) continue;
options.push({
id: `local_${lp.id}`,
name,
source: 'local',
filamentId: genericFilamentIdForMaterial(material),
filamentType: material || '',
});
}
// 2. Orca Cloud. setting_ids are UUIDs a Bambu printer can't resolve, so
// these also fall back to the generic id for their material.
for (const op of orcaProfiles ?? []) {
const name = presetDisplayName(op.name);
const material = parsePresetName(name).material;
// Same reasoning as the local tier for the generic id. The Orca profile id
// is claimed, so a Bambu Cloud row carrying that same id is recognised as
// the same record — a shared *name* is not, since an Orca library and an
// imported bundle are usually the same profiles reached two ways and both
// are worth showing under their own heading.
if (!take('orca_cloud', name, op.setting_id)) continue;
options.push({
id: `orca_${op.setting_id}`,
name,
source: 'orca_cloud',
filamentId: genericFilamentIdForMaterial(material),
filamentType: material,
});
}
// 3. Bambu Cloud. Official presets (GFS…) carry their filament id in the
// setting_id itself; user presets (PFUS… / PFCN…) do not, and toFilamentId
// would hand back the raw cloud id, which the printer rejects. Leave those
// empty here and let resolveFilamentId fetch the detail on selection.
for (const cp of cloudSettings ?? []) {
const name = presetDisplayName(cp.name);
// Cloud setting_ids carry a variant suffix ("GFSA00_01"); claim the bare
// filament id as well, or the built-in tier won't recognise the filament
// as covered and will list it again under its own heading.
const filamentId = cp.setting_id.startsWith('GFS') ? toFilamentId(cp.setting_id) : '';
if (!take('cloud', name, cp.setting_id, filamentId || undefined)) continue;
options.push({
id: cp.setting_id,
name,
source: 'cloud',
filamentId,
filamentType: parsePresetName(name).material,
});
}
// 4. Hardcoded fallback. Always present, so the picker is never empty even
// with no cloud account and nothing imported — but only for filaments none
// of the tiers above already offered.
for (const bf of builtinFilaments ?? []) {
// Cloud setting_ids insert an "S" after "GF" ("GFA00" → "GFSA00"); check
// both spellings so a filament a cloud tier already offered isn't listed
// a second time under a slightly different id.
const asSettingId = bf.filament_id.startsWith('GF') ? `GFS${bf.filament_id.slice(2)}` : bf.filament_id;
// Unlike the tiers above, a name match is enough to skip: this table is a
// static copy of the same catalogue, not a library of its own.
if (namesOffered.has(nameKey(bf.name))) continue;
if (!take('builtin', bf.name, bf.filament_id, asSettingId)) continue;
options.push({
id: `builtin_${bf.filament_id}`,
name: bf.name,
source: 'builtin',
filamentId: bf.filament_id,
filamentType: parsePresetName(bf.name).material,
});
}
return options.sort((a, b) => {
if (a.source !== b.source) return SOURCE_ORDER[a.source] - SOURCE_ORDER[b.source];
return a.name.localeCompare(b.name);
});
}
/**
* The Bambu filament id to file a calibration under for a chosen preset.
*
* Everything except a Bambu Cloud *user* preset is already resolved by
* buildFilamentPresetOptions; those need the cloud detail, because the
* PFUS/PFCN setting_id is not a filament id and the printer's calibration
* table is indexed by filament id. ``fetchDetail`` is injected so the pure
* cases stay testable without a network stub.
*/
export async function resolveFilamentId(
option: FilamentPresetOption,
fetchDetail?: (settingId: string) => Promise<{ filament_id?: string | null }>,
): Promise<string> {
if (option.filamentId) return option.filamentId;
if (option.source !== 'cloud' || !fetchDetail) return '';
try {
const detail = await fetchDetail(option.id);
// Never fall back to the preset's base_id: that collapses a custom preset
// onto the generic it inherits from, and the printer then resolves the
// calibration to "Generic …" instead of the user's filament (#1053).
return detail.filament_id || '';
} catch {
return '';
}
}
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
+2 -2
View File
@@ -26,8 +26,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CxAiFpme.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-oReXTzKG.css">
<script type="module" crossorigin src="/assets/index-CbDmTKuP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C_6BSgrK.css">
</head>
<body>
<div id="root"></div>