Merge branch 'dev' into feature/2656-restore-from-github

This commit is contained in:
MartinNYHC
2026-08-05 11:16:49 +02:00
committed by GitHub
14 changed files with 9451 additions and 87 deletions
+4 -2
View File
@@ -14,7 +14,9 @@ All notable changes to Bambuddy will be documented in this file.
- **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests.
### Fixed
- **A drying cycle no longer reports itself finished a minute after it starts (#2759)** — Starting the dryer on an AMS 2 Pro holding two PETG and two PLA spools and picking PLA showed "PLA @ 45°C" for about a minute, then switched to "PETG @ 65°C" for the remaining twelve hours. Bambu never echoes back which filament or temperature a cycle is running, so the badge reads the target Bambuddy cached when it sent the command — and that cache had been thrown away. Between accepting the command and settling its countdown the firmware publishes one update with the remaining time at zero while the unit is still in its Checking phase; the reporter's log caught 720 minutes, then 0, then 719. Bambuddy read the zero as the cycle ending. Losing the cached target left the badge to guess the filament from the first loaded slot, which happened to be PETG, and its RFID-recommended 65°C — a confident wrong answer for a cycle running PLA at 45. The same false ending also armed smart-plug auto-off-after-drying, so anyone with that switched on had power scheduled to cut one minute into a twelve-hour dry. A remaining time of zero is now only treated as the end of a cycle when the AMS also reports an idle phase, which the firmware already publishes alongside it; stopping a dry early still ends it immediately, and a unit that reports no phase at all still ends its cycles as before. The fallback guess has been tightened to match: it now answers only when every loaded spool is the same filament, and on a mixed unit the badge shows the countdown alone rather than naming a temperature the cycle isn't using. Covered by backend tests.
- **LDAP login works again on directories that define no POSIX group class (#2769, reporter @peterskotte)** — Every LDAP user on an lldap directory was rejected with "Incorrect username or password", including users whose credentials, search filter and group membership all checked out when tested by hand with `ldapsearch`, and on an install where **Test Connection** reported success. The password was never the problem and the directory never saw the request. When resolving a user's groups Bambuddy looks for POSIX groups alongside the usual `memberOf` ones, and both of those searches name the `posixGroup` object class. The LDAP client validates class names in a filter against the schema the server publishes, and rejects an unknown one while building the request, before anything is sent. lldap marks every account it creates as `posixAccount`, which is what makes Bambuddy look for POSIX groups in the first place, but defines no group class beyond `groupOfNames` — so the search was refused, the refusal travelled all the way out of the login routine, and the login route reports any LDAP failure as bad credentials. A directory with no `posixGroup` class has no `posixGroup` entries, which is precisely the answer those searches would have returned, so Bambuddy now treats the refusal as the empty result it stands for, notes it once in the log and carries on with the `memberOf` groups. The reporter's mapped group is one of those, so it resolves as configured. This is not a regression from the recent primary-group work, though that is the natural suspect: the `memberUid` search has named the same class since LDAP support first shipped, and it runs for every user whether or not they have a `gidNumber`, so login has never worked against a directory of this shape. **Test Connection** passed throughout because it asks only whether any entry exists, a form of filter that carries no class name to validate. Nothing changes for Active Directory or for an OpenLDAP that loads the standard NIS schema — both define the class, and their POSIX groups are still read. Wiki updated. Covered by backend tests.
- **Spoolman no longer charges a Bambu Studio print to the wrong spool (#2768)** — A sliced file numbers its filaments 1, 2, 3, 4, and which AMS tray each of those came from is a separate decision made when the job is sent. Bambuddy learns that decision one of two ways: it made the choice itself, for a print started from Bambuddy, or it read the print command as it crossed the local network, for a print sent from a slicer. A job dispatched from Bambu Studio while the printer is signed in to Bambu's cloud satisfies neither — the command travels through Bambu's own broker and never appears on the network Bambuddy is listening to. With nothing recorded, the Spoolman writer fell back to assuming the AMS was loaded in slicer order: filament 1 from the first loaded tray, filament 2 from the second. The reporter's X1C was loaded in the order 2, 4, 1, AMS-HT, so every one of the four was deducted from the wrong spool. It also changed what the print looked like afterwards: on completion Bambuddy stamps the archive with the material and colour of the spools it charged, so the print showed the right filament while it ran and switched to a different one the moment it finished — which is how the reporter noticed. The printer knew the answer all along. It publishes the running job's slot-to-tray assignment in its own status, and Bambuddy's built-in filament inventory has read that field for as long as it has resolved mappings at completion; only the Spoolman writer, which resolves at print start instead, never learned to. It now consults the same two fallbacks at the same moment: the printer's report first, and failing that a colour match of the sliced filaments against the loaded trays, which covers the A1, A1 Mini, P1S and P2S — those models publish no such field, so their owners were on the positional guess no matter how the print was sent. Reading the field at completion rather than at print start is deliberate: a printer keeps publishing the last job's mapping while it sits idle, so consulting it early risks stamping the previous print's mapping onto this one. A mapping Bambuddy or the slicer actually recorded is never second-guessed, so nothing changes for prints started from Bambuddy, from the queue, or over LAN. Cancelled and failed prints take the same correction, since partial usage is charged through the same mapping. The resolved mapping and where it came from are now logged at both print start and completion, so the next report of a wrong deduction can be read straight out of a support bundle. Wiki updated. Covered by backend tests.
- **A drying cycle no longer reports itself finished a minute after it starts (#2759)** — Starting the dryer on an AMS 2 Pro holding two PETG and two PLA spools and picking PLA showed "PLA @ 45°C" for about a minute, then switched to "PETG @ 65°C" for the remaining twelve hours. Bambu never echoes back which filament or temperature a cycle is running, so the badge reads the target Bambuddy cached when it sent the command — and that cache had been thrown away. Between accepting the command and settling its countdown the firmware publishes one update with the remaining time at zero while the unit is still in its Checking phase; the reporter's log caught 720 minutes, then 0, then 719. Bambuddy read the zero as the cycle ending. Losing the cached target left the badge to guess the filament from the first loaded slot, which happened to be PETG, and its RFID-recommended 65°C — a confident wrong answer for a cycle running PLA at 45. The same false ending also armed smart-plug auto-off-after-drying, so anyone with that switched on had power scheduled to cut one minute into a twelve-hour dry. A remaining time of zero is now only treated as the end of a cycle when the AMS also reports an idle phase, which the firmware already publishes alongside it; stopping a dry early still ends it immediately, and a unit that reports no phase at all still ends its cycles as before. The fallback guess has been tightened to match, in both directions. It names a filament only when every loaded spool agrees on one — on a mixed unit the badge shows the countdown alone rather than naming a spool the cycle isn't drying — and it no longer guesses a temperature at all. A unit loaded entirely with PLA does tell you what is being dried, but not at what temperature: that is picked freely when the cycle is started, so the spools' RFID-recommended value is never evidence of it, and a second AMS loaded only with PLA and drying at 45°C still read "PLA @ 55°C" whenever the cached target went missing. The badge now names a temperature only when Bambuddy sent it, and shows the filament and countdown without one otherwise. Covered by backend and frontend tests.
- **A print that never starts now says AMS drying was running, instead of blaming the SD card (#2758)** — Sending a job to an X2D with two AMS units mid-drying failed silently: the file uploaded, the printer accepted it and then simply stayed idle. Bambuddy waited out the start watchdog, re-uploaded the whole 3MF, waited again, and after three attempts gave up with advice to check the printer's screen and the SD card — while Bambu Studio, asked directly, said it could not start the job because of the drying. Bambuddy now watches the AMS drying telemetry it already receives across the dispatch window and, when a job never starts while a unit was drying, names the units in the failure message and records the correlation in the log from the first attempt rather than only after the retries are spent. This is deliberately a diagnosis and not a rule: the printers concerned support drying *continuing* through a print, so drying and printing are not in conflict as such, and the report also involved one AMS drying without its external power supply — which would make the start-of-print calibration a power problem rather than a drying one. Stopping the cycle automatically would therefore be acting on a guess, and could tear down drying the hardware was happy to continue. Until it is known which of the two is the real obstacle, Bambuddy tells you what it saw and leaves the call to you. The message for a stalled dispatch with no drying involved is unchanged. Wiki updated. Covered by backend tests.
- **A hand-written systemd service left the Virtual Printer unable to start, with nothing obvious to blame (#2549, reporter @Ru3ck3)** — The Virtual Printer binds ports 990 and 322, both below 1024, which a service running as a normal user may not do without the `CAP_NET_BIND_SERVICE` capability. Without it the rest of Bambuddy works perfectly and only the Virtual Printer is dead: its sockets never open, the slicer never finds the printer, and the sole trace is one line in the journal. The reporter lost days to this before someone on Discord spotted the missing line. The install script has carried it since March, but the three other places that define the same service did not — the manual-install template, the combined Bambuddy plus SpoolBuddy installer, and the unit the wiki tells you to paste. All three have it now, and the wiki no longer claims the capability is always included when its own instructions omitted it. Bambuddy also diagnoses this itself: **Diagnose** on the virtual printer card previously reported only that nothing was listening on port 990, which reads identically to an ordinary port conflict. It now checks whether the process actually holds the capability and, when that is what is wrong, says so and gives the line to add. The check stays quiet when the port is answering, since fronting it another way (an iptables redirect is the documented alternative) is a legitimate setup, and it stays quiet when the capability is held, so a port that failed for some other reason is not misattributed. Existing installs are unaffected until reinstalled; the diagnostic tells you whether yours needs the line. Translated in all locales; wiki updated. Covered by backend tests.
- **A refused AMS filament setting now says so in the log (#2756, reporter @Jostxxl)** — Configuring a slot publishes an `ams_filament_setting` command, and the printer answers it with a verdict. That answer was received and then thrown away at debug level, so a printer that refused the write left no trace at the log level support bundles are collected at. The reporter hit exactly that: six manual **Configure Slot** attempts on one X1C, every one returning success, every one read back by the #2582 verification as still holding the previous profile, and nothing anywhere to say what the printer had made of the command. A refusal is now logged with the printer's own `result` and `reason` alongside the AMS and tray it concerned. Only refusals are promoted — unlike the K-profile and drying commands this one is not rare, since every spool assignment and every K-profile re-apply sends one, and logging each acknowledgement would bury the line worth reading. The developer-mode probe is excluded as well: it sends this same command to the external slot specifically to watch it be refused on P1 firmware, so its failure is a measurement rather than a fault. Diagnostics only — nothing about which commands are sent or how they are built has changed. Covered by backend tests.
@@ -107,7 +109,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Force color match dispatched a print onto the wrong PLA variant — Matte jobs went to Basic and Silk printers alike, and the wrong AMS slot on a printer holding two same-colour variants (#2650, reporter @MartinNYHC)** — With **Force color match** on, a job sliced for **White PLA Matte** was dispatched to every printer that had *any* white PLA loaded — the ones holding White PLA **Basic** and White PLA **Silk+** included — so a matte model came out glossy on the wrong machine. **Root cause.** Bambu's MQTT status reports every PLA sub-variant as `tray_type == "PLA"`; the Basic/Matte/Silk distinction is carried only in `tray_info_idx` (`GFA00` = Basic, `GFA01` = Matte, `GFA06` = Silk, …), which the 3MF's `slice_info.config` also records per filament. Three places dropped it: the Virtual-Printer queue built each force override as `{slot_id, type, color, force_color_match}` without the parsed `tray_info_idx`; the scheduler's eligibility check (`_get_missing_force_color_slots`) compared loaded trays on `(type, colour)` only — so `(PLA, #FFFFFF)` matched Basic, Matte and Silk indiscriminately and all three printers looked eligible; and the AMS slot mapper cleared `tray_info_idx` when applying the override, so even on the correct printer it could pick a different-variant tray of the same colour. **Fix.** The force override now carries the 3MF's `tray_info_idx`; a slot counts as satisfied only when a loaded tray matches type **and** colour **and** the variant (identical `tray_info_idx`, *or* either side lacks one); and the slot mapper now keeps the variant for force-colour overrides so it pins the matching tray. A blank idx on either side (custom/third-party spools report none, and older 3MFs carry none) falls back to the historical type+colour behaviour, so those setups are unaffected, and a manual filament *swap* (a preference override) still clears the idx so it matches the swapped-in spool rather than the old one. A job sliced for GFA01 now goes only to a printer with GFA01 loaded, and lands on that printer's GFA01 tray. The printer-card queue-compatibility hint (which printers show a pending job as runnable) now applies the same variant rule. Covered by scheduler tests (Matte requirement rejects Basic/Silk, accepts Matte, blank loaded idx falls back, requirement without an idx unchanged; the mapper pins the GFA01 tray over a same-colour GFA00 on both the 3MF and no-3MF paths; a preference swap still matches by colour), a Virtual-Printer test asserting the override carries `tray_info_idx`, and frontend tests for the variant-aware queue hint (rejects other variants, accepts the match, blank-idx and no-variant-data fall back).
### Security
- **Patched two build-time frontend dependencies flagged by `npm audit` (GHSA-r28c-9q8g-f849, GHSA-mh99-v99m-4gvg)** — `postcss` 8.5.15 → 8.5.23 fixes a path traversal in its source-map auto-loader (`sourceMappingURL`) that could disclose arbitrary `.map` files, and `brace-expansion` (pulled in transitively by `eslint` via `minimatch`) is bumped through the existing `overrides` block (`^5.0.7` → `^5.0.8`) for a denial-of-service via unbounded expansion. Both are build/lint-time tooling only — neither is part of the shipped app, so no running Bambuddy install was exposed. `postcss` moved within its existing range; `brace-expansion` needed the pin because `npm audit fix` can't lift `eslint` to the patched transitive on its own.
- **Patched two build-time frontend dependencies flagged by `npm audit` (GHSA-r28c-9q8g-f849, GHSA-mh99-v99m-4gvg, GHSA-rgw5-rvv9-x895)** — `postcss` 8.5.15 → 8.5.23 fixes a path traversal in its source-map auto-loader (`sourceMappingURL`) that could disclose arbitrary `.map` files, and `brace-expansion` (pulled in transitively by `eslint` via `minimatch`) is bumped through the existing `overrides` block (`^5.0.7` → `^5.0.9`) for a denial-of-service via unbounded expansion. The first `brace-expansion` advisory was answered in 5.0.8 by capping the length of the combined result, but that cap covered only the accumulator the results are merged into and not the two intermediate arrays that feed it — so a small brace pattern could still exhaust the heap, fatally and beyond the reach of a `try`/`catch`, or stall the event loop for minutes. 5.0.9 bounds both arrays as they are built. Both packages are build/lint-time tooling only — neither is part of the shipped app, so no running Bambuddy install was exposed. `postcss` moved within its existing range; `brace-expansion` needed the pin because `npm audit fix` can't lift `eslint` to the patched transitive on its own.
- **Pinned `react-router` to its most-patched 7.x (7.18.1) and documented the one remaining, unreachable advisory (GHSA-qwww-vcr4-c8h2)** — Staying current on the 7.x line matters: 7.18.1 clears 14 advisories that older 7.x releases carry, several reachable in a browser SPA (open-redirect XSS in `<Link>`/`useNavigate`, route-matching DoS). The single advisory that still flags 7.18.1 — a CSRF bypass — applies only to React Router's **RSC mode**, which requires the server runtime (`@react-router/server`, not installed); Bambuddy is a Vite SPA using `BrowserRouter`, so the vulnerable path is unreachable. There is no non-major fix (the patch landed only in the 8.3.0 major, and `react-router-dom` has no 8.x — adopting it would mean migrating every import to `react-router` plus a React peer bump), so `react-router`/`react-router-dom` are pinned to 7.18.1 and the finding is carried as a documented, fail-closed exception in the CI audit gate: a *different* react-router advisory still fails CI, and the exemption is dropped automatically the moment a non-major fix ships. `npm audit fix --force` is deliberately avoided — its suggested "fix" is a downgrade to 7.11.0, which reintroduces those 14 advisories.
+7 -13
View File
@@ -62,7 +62,7 @@ from backend.app.services.printer_manager import (
supports_chamber_temp,
supports_drying,
supports_drying_while_printing,
uniform_tray_drying_hint,
uniform_tray_filament_hint,
)
from backend.app.utils.filament_ids import filament_id_to_setting_id
from backend.app.utils.http import build_content_disposition
@@ -578,18 +578,12 @@ async def get_printer_status(
dry_target_temp = None
if target_fil_val:
dry_filament = str(target_fil_val)
# Fallback: derive from the loaded trays when there is no cached
# target (drying started in a previous backend session, or the
# cache wasn't seeded), and only when they agree on a filament
# type. See uniform_tray_drying_hint.
if dry_target_temp is None or not dry_filament:
hint_filament, hint_temp = uniform_tray_drying_hint(
[(tray.tray_type or "", tray.drying_temp) for tray in trays]
)
if not dry_filament:
dry_filament = hint_filament
if dry_target_temp is None:
dry_target_temp = hint_temp
# Fallback: name the filament from the loaded trays when there is no
# cached target (drying started in a previous backend session, or
# the cache wasn't seeded), and only when they agree. The
# temperature has no fallback — see uniform_tray_filament_hint.
if not dry_filament:
dry_filament = uniform_tray_filament_hint([tray.tray_type or "" for tray in trays])
ams_units.append(
AMSUnit(
+42 -18
View File
@@ -14,6 +14,7 @@ import logging
from dataclasses import dataclass
from ldap3 import ALL, SUBTREE, Connection, Server, Tls
from ldap3.core.exceptions import LDAPObjectClassError
logger = logging.getLogger(__name__)
@@ -155,32 +156,55 @@ def _extract_user_info(
canonical_username = _pick_canonical_username(user_entry, fallback_username)
# Also search for POSIX groups (memberUid-based) using the service account
posix_filter = f"(&(objectClass=posixGroup)(memberUid={_ldap_escape(canonical_username)}))"
service_conn.search(
search_base=config.search_base,
search_filter=posix_filter,
search_scope=SUBTREE,
attributes=["cn"],
)
for entry in service_conn.entries:
groups.append(str(entry.entry_dn))
# POSIX primary group: user's gidNumber matches a posixGroup's gidNumber.
# Standard Unix semantics treat this as full group membership, so we need
# to resolve it to a group DN alongside the memberUid results.
if hasattr(user_entry, "gidNumber") and user_entry.gidNumber:
primary_gid = str(user_entry.gidNumber)
primary_filter = f"(&(objectClass=posixGroup)(gidNumber={_ldap_escape(primary_gid)}))"
# Also search for POSIX groups, both the memberUid kind and the primary
# gidNumber kind. Both filters name the posixGroup object class, and ldap3
# validates that name against the schema it fetched at connect time
# (get_info=ALL) before it builds the request — so on a directory that
# publishes a schema without posixGroup it raises client-side and nothing is
# ever sent. A directory with no posixGroup class has no posixGroup entries,
# which is exactly the answer the searches would have returned, so the
# correct response is to carry on with the memberOf groups collected above.
#
# Left uncaught, that exception escaped authenticate_ldap_user, and the login
# route reports any LDAP error as "Incorrect username or password" — so an
# lldap user, whose accounts carry posixAccount but whose directory defines
# no group classes beyond groupOfNames, could never log in and had nothing
# but a wrong-password message to go on (#2769). This predates the primary
# gidNumber lookup: the memberUid filter has named the class since #794.
try:
posix_filter = f"(&(objectClass=posixGroup)(memberUid={_ldap_escape(canonical_username)}))"
service_conn.search(
search_base=config.search_base,
search_filter=primary_filter,
search_filter=posix_filter,
search_scope=SUBTREE,
attributes=["cn"],
)
for entry in service_conn.entries:
groups.append(str(entry.entry_dn))
# POSIX primary group: user's gidNumber matches a posixGroup's gidNumber.
# Standard Unix semantics treat this as full group membership, so we need
# to resolve it to a group DN alongside the memberUid results.
if hasattr(user_entry, "gidNumber") and user_entry.gidNumber:
primary_gid = str(user_entry.gidNumber)
primary_filter = f"(&(objectClass=posixGroup)(gidNumber={_ldap_escape(primary_gid)}))"
service_conn.search(
search_base=config.search_base,
search_filter=primary_filter,
search_scope=SUBTREE,
attributes=["cn"],
)
for entry in service_conn.entries:
groups.append(str(entry.entry_dn))
except LDAPObjectClassError:
# Logged once per authentication, at info: it is the explanation for a
# user's POSIX groups being absent from their mapping, and it is not an
# error the operator can or should act on.
logger.info(
"Directory publishes no posixGroup object class; skipping POSIX group lookup "
"(memberOf groups are unaffected)"
)
# Dedupe group DNs (user may be in a group via both memberUid and primary gidNumber).
# Case-insensitive comparison — LDAP DNs are case-insensitive by spec.
seen_lower: set[str] = set()
+26 -30
View File
@@ -280,8 +280,8 @@ def display_temperatures(temperatures: dict | None, model: str | None) -> dict[s
return out
def uniform_tray_drying_hint(loaded_trays: list[tuple[str, object]]) -> tuple[str | None, int | None]:
"""Guess an active cycle's filament + target temperature from the loaded trays.
def uniform_tray_filament_hint(loaded_types: list[str]) -> str | None:
"""Guess an active cycle's filament from the loaded trays.
Bambu never echoes back which filament or temperature a drying cycle is
running, so the badge normally reads the target we cached when we sent the
@@ -291,30 +291,31 @@ def uniform_tray_drying_hint(loaded_trays: list[tuple[str, object]]) -> tuple[st
It answers only when every loaded tray holds the same filament type. On a
mixed unit the first tray is evidence of nothing: an AMS holding two PETG
and two PLA spools, drying PLA at the 45°C the user picked, was labelled
"PETG @ 65°C" purely because slot 1 happened to be PETG (#2759). Saying
nothing and letting the badge show just the countdown beats stating a
temperature the cycle isn't using.
"PETG @ 65°C" purely because slot 1 happened to be PETG (#2759).
Deliberately no temperature. The RFID-recommended ``drying_temp`` used to be
returned alongside a uniform filament, which narrowed #2759 to units whose
spools disagree but left the uniform case stating a temperature just as
invented: a unit loaded entirely with PLA, drying at the 45°C the user
picked, read "PLA @ 55°C" the moment the cached target went missing. The
filament type is real evidence — every spool in the unit agrees on it, and
the dryer heats all of them — but the temperature is a free choice in the
popover, so a recommendation is never evidence of what is running. The badge
shows the filament and the countdown, and names a temperature only when we
actually sent it.
Args:
loaded_trays: ``(tray_type, drying_temp)`` for each tray, in slot order.
Empty slots (falsy tray_type) are ignored. ``drying_temp`` is the
RFID-recommended value and may be None or unparseable.
loaded_types: ``tray_type`` for each tray, in slot order. Empty slots
(falsy) are ignored.
Returns:
``(filament, temp)``, either of which may be None.
The shared filament type, or None if the loaded trays disagree or the
unit is empty.
"""
types = {str(tray_type) for tray_type, _ in loaded_trays if tray_type}
types = {str(tray_type) for tray_type in loaded_types if tray_type}
if len(types) != 1:
return None, None
filament = next(iter(types))
for tray_type, drying_temp in loaded_trays:
if not tray_type or not drying_temp:
continue
try:
return filament, int(drying_temp)
except (TypeError, ValueError):
continue
return filament, None
return None
return next(iter(types))
def supports_drying(model: str | None, firmware: str | None) -> bool:
@@ -1333,8 +1334,9 @@ def printer_state_to_dict(
# per-tick AMS push, so prefer the cached target from the last
# ``send_drying_command``. When we have no record (drying
# started in a previous backend lifetime, or the cache was
# never seeded), fall back to the loaded trays — but only when
# they agree on a filament type. See uniform_tray_drying_hint.
# never seeded), the loaded trays can still name the filament
# if they agree — but never the temperature, which only the
# cache knows. See uniform_tray_filament_hint.
ams_id_int = int(ams_data.get("id", 0))
target = (drying_targets or {}).get(ams_id_int)
dry_target_temp: int | None = None
@@ -1349,14 +1351,8 @@ def printer_state_to_dict(
dry_target_temp = None
if fil_val:
dry_filament = str(fil_val)
if dry_target_temp is None or not dry_filament:
hint_filament, hint_temp = uniform_tray_drying_hint(
[(tray.get("tray_type") or "", tray.get("drying_temp")) for tray in trays]
)
if not dry_filament:
dry_filament = hint_filament
if dry_target_temp is None:
dry_target_temp = hint_temp
if not dry_filament:
dry_filament = uniform_tray_filament_hint([tray.get("tray_type") or "" for tray in trays])
ams_units.append(
{
+94 -2
View File
@@ -150,6 +150,62 @@ def _resolve_global_tray_id(slot_id: int, slot_to_tray: list | None, ams_trays:
return slot_id - 1
def _resolve_slot_to_tray_fallback(printer_id: int, filament_usage: list[dict]) -> tuple[list[int] | None, str]:
"""Recover a slot-to-tray mapping at completion when print start captured none.
``store_print_data`` can only learn the mapping from two sources: the
``ams_mapping`` Bambuddy intercepts on the printer's local request topic, and
a queue item's stored mapping. Neither exists for a print dispatched from
Bambu Studio while the printer is cloud-bound — the command travels through
Bambu's broker and never appears on the local topic we subscribe to. With
``slot_to_tray`` left NULL, ``_resolve_global_tray_id`` guesses by position:
slicer slot 1 to the first loaded tray, slot 2 to the second, and so on. An
AMS that isn't loaded in slicer order then charges every slot to the wrong
spool, and the archive's filament is rewritten to match, so the print
silently changes colour when it finishes (#2768).
The printer knows the real answer. Its ``mapping`` field carries the actual
slot-to-tray assignment for the running job, and for the models that never
publish it (A1, P1S, P2S) the 3MF's per-slot colours can be matched against
the loaded trays instead. The built-in inventory writer has consulted both
for as long as it has resolved mappings at completion; this gives the
Spoolman writer the same two fallbacks at the same moment.
Deliberately at completion rather than inside ``store_print_data``: the
printer keeps publishing ``mapping`` long after a job ends — it is still in
the status payload while the printer sits idle — so reading it at print start
risks stamping the *previous* job's mapping onto this one before the printer
has pushed the update. At completion the field unambiguously describes the
job that just ran.
Args:
printer_id: Printer whose live state is consulted.
filament_usage: The 3MF's per-slot estimates, needed by the colour
match. Only the ``slot_id``/``color`` keys are read.
Returns:
``(mapping, source)``, or ``(None, "none")`` when neither fallback
produced anything and the positional default stands.
"""
from backend.app.services.printer_manager import printer_manager
from backend.app.services.usage_tracker import _decode_mqtt_mapping, _match_slots_by_color
state = printer_manager.get_status(printer_id)
raw_data = getattr(state, "raw_data", None) if state else None
if not raw_data:
return None, "none"
decoded = _decode_mqtt_mapping(raw_data.get("mapping"))
if decoded:
return decoded, "mqtt"
matched = _match_slots_by_color(filament_usage, raw_data.get("ams"))
if matched:
return matched, "color_match"
return None, "none"
def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
"""Build lookup of global_tray_id -> tray info from printer state.
@@ -327,9 +383,11 @@ async def store_print_data(
# Prefer the explicit mapping captured from the print command, then fall back
# to any queue mapping stored for scheduled/reprint jobs.
slot_to_tray = ams_mapping if ams_mapping is not None else None
mapping_source = "print_cmd" if slot_to_tray else None
if not slot_to_tray and queue_item and queue_item.ams_mapping:
try:
slot_to_tray = json.loads(queue_item.ams_mapping)
mapping_source = "queue"
except json.JSONDecodeError:
pass # Ignore malformed AMS mapping; fall back to default slot assignment
@@ -364,8 +422,15 @@ async def store_print_data(
)
logger.debug("[SPOOLMAN] Filament usage: %s", filament_usage)
logger.debug("[SPOOLMAN] AMS trays: %s", list(ams_trays.keys()))
if slot_to_tray:
logger.debug("[SPOOLMAN] Custom slot mapping: %s", slot_to_tray)
# Logged at info even when there is no mapping: "source: none" here is the
# signal that completion will have to fall back, which is the single most
# useful line in the log when a print is charged to the wrong spool (#2768).
logger.info(
"[SPOOLMAN] Print start: archive %s slot_to_tray=%s (source: %s)",
archive_id,
slot_to_tray,
mapping_source or "none",
)
if layer_usage_json:
logger.debug("[SPOOLMAN] Layer usage data available for partial tracking")
@@ -819,6 +884,19 @@ async def _report_partial_usage(
)
return
# Same recovery the completion path does, for the same reason: a print
# dispatched from Studio over the cloud left print start with no mapping to
# store, and both paths below feed ``slot_to_tray`` to
# ``_resolve_global_tray_id`` (#2768). An aborted print charges the wrong
# spool just as readily as a finished one.
if not slot_to_tray:
slot_to_tray, _partial_mapping_source = _resolve_slot_to_tray_fallback(printer_id, filament_usage)
logger.info(
"[SPOOLMAN] Partial usage: slot_to_tray=%s (source: %s)",
slot_to_tray,
_partial_mapping_source,
)
# Try to use accurate G-code parsed data
if layer_usage:
layer_usage_int = {
@@ -1000,6 +1078,20 @@ async def report_usage(printer_id: int, archive_id: int):
# is the print's last valid layer.
_layer_denom_hint = _total_layers or _current_layer
# Recover the mapping when print start had nothing to store — the
# cloud-dispatched Studio print of #2768. Only the 3MF path consumes
# ``slot_to_tray``; the remain-delta path below resolves spools from the
# AMS slot directly, so there is nothing to recover for it.
mapping_source = "stored" if slot_to_tray else "none"
if filament_usage and not slot_to_tray:
slot_to_tray, mapping_source = _resolve_slot_to_tray_fallback(printer_id, filament_usage)
logger.info(
"[SPOOLMAN] Archive %s: slot_to_tray=%s (source: %s)",
archive_id,
slot_to_tray,
mapping_source,
)
slot_colors: dict[int, str] = {}
slot_materials: dict[int, str] = {}
handled_global_tray_ids: set[int] = set()
@@ -11,6 +11,7 @@ are not tested here — they require a live LDAP server.
"""
import pytest
from ldap3.core.exceptions import LDAPObjectClassError
from backend.app.services.ldap_service import (
LDAPConfig,
@@ -297,6 +298,11 @@ class _MockConnection:
_search_fixture: dict[str, list] = {}
_instances: list["_MockConnection"] = []
# Filter substring that should raise LDAPObjectClassError instead of
# searching, standing in for ldap3's client-side schema validation — it
# rejects an object class the server's published schema doesn't define
# before the request is ever built (#2769).
_raise_object_class_error_on: str | None = None
def __init__(self, *args, **kwargs):
self.entries: list = []
@@ -320,6 +326,9 @@ class _MockConnection:
# **kwargs absorbs ldap3 options like size_limit that the real client supports
self.search_calls.append(search_filter or "")
self.last_attrs = list(attributes) if attributes is not None else None
needle = _MockConnection._raise_object_class_error_on
if needle and needle in (search_filter or ""):
raise LDAPObjectClassError(f"invalid class in objectClass attribute: {needle}")
for needle, entries in _MockConnection._search_fixture.items():
if needle in (search_filter or ""):
self.entries = entries
@@ -333,6 +342,7 @@ def mock_ldap(monkeypatch):
"""Patch Connection + _create_server in ldap_service so authenticate_ldap_user can run offline."""
_MockConnection._search_fixture = {}
_MockConnection._instances = []
_MockConnection._raise_object_class_error_on = None
monkeypatch.setattr("backend.app.services.ldap_service.Connection", _MockConnection)
monkeypatch.setattr("backend.app.services.ldap_service._create_server", lambda config: None)
return _MockConnection
@@ -433,6 +443,84 @@ class TestAuthenticateLdapUserGroups:
assert gidnumber_searches == []
class TestDirectoryWithoutPosixGroupClass:
"""A directory whose published schema defines no posixGroup class (#2769).
ldap3 fetches the schema at connect time (get_info=ALL) and validates object
class names in a filter against it before building the request, so both POSIX
group searches raise client-side and nothing reaches the server. lldap is the
case in the wild: it puts posixAccount on every account it creates, which
gives each user a gidNumber, but defines no group class beyond groupOfNames.
Left uncaught the exception escaped authenticate_ldap_user and the login route
reported it as "Incorrect username or password", so LDAP login was impossible.
"""
def test_authenticates_and_keeps_memberof_groups(self, mock_ldap):
"""The reporter's setup: the mapped membership comes from memberOf, which
is read off the user entry and never touches a posixGroup filter."""
user_entry = _MockEntry(
"uid=peter,ou=people,dc=fablab,dc=test",
uid="peter",
gidNumber=1001, # lldap gives every account one
memberOf=["cn=AAUStudents,ou=groups,dc=fablab,dc=test"],
)
mock_ldap._search_fixture = {"(uid=peter)": [user_entry]}
mock_ldap._raise_object_class_error_on = "objectClass=posixGroup"
info = authenticate_ldap_user(_base_config(), "peter", "password")
assert info is not None
assert info.groups == ["cn=AAUStudents,ou=groups,dc=fablab,dc=test"]
def test_authenticates_with_no_groups_at_all(self, mock_ldap):
"""No memberOf either. The user still gets in — auto-provisioning assigns
the configured default group, which is the whole point of that setting."""
user_entry = _MockEntry("uid=peter,ou=people,dc=fablab,dc=test", uid="peter", gidNumber=1001)
mock_ldap._search_fixture = {"(uid=peter)": [user_entry]}
mock_ldap._raise_object_class_error_on = "objectClass=posixGroup"
info = authenticate_ldap_user(_base_config(), "peter", "password")
assert info is not None
assert info.username == "peter"
assert info.groups == []
def test_abandons_the_primary_gid_search_after_the_first_rejection(self, mock_ldap):
"""Both filters name the same class, so once one is rejected the other
cannot succeed. Attempting it would only produce a second identical
exception to swallow."""
user_entry = _MockEntry("uid=peter,ou=people,dc=fablab,dc=test", uid="peter", gidNumber=1001)
mock_ldap._search_fixture = {"(uid=peter)": [user_entry]}
mock_ldap._raise_object_class_error_on = "objectClass=posixGroup"
authenticate_ldap_user(_base_config(), "peter", "password")
service_conn = _MockConnection._instances[0]
posix_searches = [call for call in service_conn.search_calls if "posixGroup" in call]
assert len(posix_searches) == 1
assert "memberUid=peter" in posix_searches[0]
def test_a_directory_that_defines_the_class_is_untouched(self, mock_ldap):
"""The guard must not cost a normal directory its POSIX groups — both
searches still run and both results still land."""
user_entry = _MockEntry("cn=mz,dc=test,dc=com", uid="mz", gidNumber=10002)
supplementary = _MockEntry("cn=bambuddy-viewers,ou=groups,dc=test,dc=com")
primary = _MockEntry("cn=bambuddy-operators,ou=groups,dc=test,dc=com")
mock_ldap._search_fixture = {
"(uid=mz)": [user_entry],
"memberUid=mz": [supplementary],
"gidNumber=10002": [primary],
}
info = authenticate_ldap_user(_base_config(), "mz", "password")
assert info.groups == [
"cn=bambuddy-viewers,ou=groups,dc=test,dc=com",
"cn=bambuddy-operators,ou=groups,dc=test,dc=com",
]
# ---------------------------------------------------------------------------
# Manual provisioning helpers — search_ldap_users + lookup_ldap_user (#1298)
# ---------------------------------------------------------------------------
@@ -1378,9 +1378,10 @@ class TestDryingTargetExposure:
assert result["ams"][0]["dry_filament"] == "PETG"
assert result["ams"][0]["dry_target_temp"] == 65
def test_falls_back_to_loaded_tray_when_no_cache(self):
"""No cached target → derive from the loaded trays' tray_type +
RFID-recommended drying_temp when they agree on a filament."""
def test_falls_back_to_loaded_tray_filament_when_no_cache(self):
"""No cached target → name the filament from the loaded trays when they
agree on a type. The temperature stays unknown: only the cache records
what we actually sent."""
state = self._state_with_ams(
{
"id": 0,
@@ -1392,7 +1393,7 @@ class TestDryingTargetExposure:
)
result = printer_state_to_dict(state, drying_targets=None)
assert result["ams"][0]["dry_filament"] == "ABS"
assert result["ams"][0]["dry_target_temp"] == 70
assert result["ams"][0]["dry_target_temp"] is None
def test_returns_none_when_no_cache_and_empty_trays(self):
"""No cache + no loaded tray with tray_type → both fields are None."""
@@ -1443,8 +1444,8 @@ class TestDryingTargetExposure:
assert result["ams"][0]["dry_target_temp"] is None
def test_fallback_survives_multiple_trays_of_one_type(self):
"""Agreement across slots is still evidence — a unit loaded entirely
with PLA keeps the fallback the mixed case gives up."""
"""Agreement across slots is still evidence of the filament — a unit
loaded entirely with PLA keeps the name the mixed case gives up."""
state = self._state_with_ams(
{
"id": 0,
@@ -1458,24 +1459,42 @@ class TestDryingTargetExposure:
)
result = printer_state_to_dict(state, drying_targets={})
assert result["ams"][0]["dry_filament"] == "PLA"
assert result["ams"][0]["dry_target_temp"] == 45
def test_fallback_takes_temp_from_a_later_tray_when_slot_one_has_none(self):
"""Only Bambu spools carry an RFID drying_temp. A third-party spool in
slot 1 alongside a genuine one of the same type should not cost us the
temperature."""
def test_uniform_unit_never_invents_a_temperature(self):
"""#2759 follow-up — the reporter's second AMS held only PLA and was
drying at the 45°C they picked, but with no cached target the badge
answered with the RFID recommendation and read "PLA @ 55°C". Every
spool agreeing tells us the filament; it tells us nothing about a
temperature the user chose freely in the popover."""
state = self._state_with_ams(
{
"id": 0,
"dry_time": 719,
"tray": [
{"id": 0, "tray_type": "PLA", "state": 11},
{"id": 1, "tray_type": "PLA", "drying_temp": 45, "state": 11},
{"id": 0, "tray_type": "PLA", "drying_temp": 55, "state": 11},
{"id": 1, "tray_type": "PLA", "drying_temp": 55, "state": 11},
],
}
)
result = printer_state_to_dict(state, drying_targets={})
assert result["ams"][0]["dry_filament"] == "PLA"
assert result["ams"][0]["dry_target_temp"] is None
def test_cached_temp_survives_a_unit_whose_trays_disagree(self):
"""The cache is authoritative for both fields. A mixed unit costs us the
filament fallback but must not touch a target we actually sent."""
state = self._state_with_ams(
{
"id": 0,
"dry_time": 719,
"tray": [
{"id": 0, "tray_type": "PETG", "drying_temp": 65, "state": 11},
{"id": 1, "tray_type": "PLA", "drying_temp": 55, "state": 11},
],
}
)
result = printer_state_to_dict(state, drying_targets={0: {"filament": "PLA", "temp": 45}})
assert result["ams"][0]["dry_filament"] == "PLA"
assert result["ams"][0]["dry_target_temp"] == 45
@@ -0,0 +1,243 @@
"""Slot-to-tray mapping fallbacks on the Spoolman path (#2768).
Bambuddy only learns a print's slot-to-tray mapping at print start when it can
intercept the command on the printer's local MQTT request topic, or when the
print came from its own queue. A print dispatched from Bambu Studio while the
printer is cloud-bound satisfies neither: the command travels through Bambu's
broker, so ``ActivePrintSpoolman.slot_to_tray`` is NULL and every slot falls
through to a positional guess (slicer slot 1 to the first loaded tray, and so
on). The reporter's X1C was loaded out of slicer order, so all four slots were
charged to the wrong spool and the archive's filament was rewritten to match.
The internal-inventory writer never had this problem because it resolves the
mapping at completion, where it can read the printer's own ``mapping`` field or
colour-match the 3MF slots against the loaded trays. These tests cover giving
the Spoolman writer the same two fallbacks.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.services.spoolman_tracking import _resolve_slot_to_tray_fallback
class _AsyncCtx:
"""Minimal async context manager yielding a stub db session."""
def __init__(self, db):
self._db = db
async def __aenter__(self):
return self._db
async def __aexit__(self, *exc):
return False
def _state(**raw):
return SimpleNamespace(raw_data=raw, layer_num=0, total_layers=0, tray_change_log=[])
def _patched_pm(state):
pm = MagicMock()
pm.get_status.return_value = state
return pm
class TestResolveSlotToTrayFallback:
def test_decodes_the_printers_own_mapping_field(self):
"""The reporter's X1C published mapping=[1, 3, 0, 32768] while their
AMS was loaded out of slicer order. Snow-encoded, that is AMS 0 slot 2,
AMS 0 slot 4, AMS 0 slot 1, and the AMS-HT — nothing like the
positional [0, 1, 2, 3] the fallback-free path assumed."""
pm = _patched_pm(_state(mapping=[1, 3, 0, 32768]))
with patch("backend.app.services.printer_manager.printer_manager", pm):
mapping, source = _resolve_slot_to_tray_fallback(1, [{"slot_id": 1, "color": "#FF0000"}])
assert mapping == [1, 3, 0, 128]
assert source == "mqtt"
def test_colour_matches_when_the_printer_publishes_no_mapping(self):
"""A1/P1S/P2S never publish the mapping field. The 3MF's per-slot
colours still identify the trays when each one is unambiguous."""
pm = _patched_pm(
_state(
ams=[
{
"id": 0,
"tray": [
{"id": 0, "tray_color": "00FF00FF", "tray_type": "PLA"},
{"id": 1, "tray_color": "FF0000FF", "tray_type": "PLA"},
],
}
]
)
)
usage = [{"slot_id": 1, "color": "#FF0000"}, {"slot_id": 2, "color": "#00FF00"}]
with patch("backend.app.services.printer_manager.printer_manager", pm):
mapping, source = _resolve_slot_to_tray_fallback(1, usage)
assert mapping == [1, 0]
assert source == "color_match"
def test_mapping_field_wins_over_colour_matching(self):
"""The printer's own field is direct evidence; colour matching is
inference. When both are available the field decides."""
pm = _patched_pm(
_state(
mapping=[3],
ams=[{"id": 0, "tray": [{"id": 0, "tray_color": "FF0000FF", "tray_type": "PLA"}]}],
)
)
with patch("backend.app.services.printer_manager.printer_manager", pm):
mapping, source = _resolve_slot_to_tray_fallback(1, [{"slot_id": 1, "color": "#FF0000"}])
assert mapping == [3]
assert source == "mqtt"
def test_reports_none_when_neither_fallback_answers(self):
"""Ambiguous colours and no mapping field: say so rather than invent
one. The caller keeps the positional default, which is no worse than
before, and the log names the reason."""
pm = _patched_pm(
_state(
ams=[
{
"id": 0,
"tray": [
{"id": 0, "tray_color": "FF0000FF", "tray_type": "PLA"},
{"id": 1, "tray_color": "FF0000FF", "tray_type": "PLA"},
],
}
]
)
)
with patch("backend.app.services.printer_manager.printer_manager", pm):
mapping, source = _resolve_slot_to_tray_fallback(1, [{"slot_id": 1, "color": "#FF0000"}])
assert mapping is None
assert source == "none"
def test_reports_none_when_the_printer_is_offline(self):
"""No live state at completion — the printer dropped off after the
print. Nothing to read, and no crash."""
with patch("backend.app.services.printer_manager.printer_manager", _patched_pm(None)):
mapping, source = _resolve_slot_to_tray_fallback(1, [{"slot_id": 1, "color": "#FF0000"}])
assert mapping is None
assert source == "none"
class TestReportUsageUsesTheFallback:
"""End-to-end through report_usage: the fallback has to reach
``_resolve_global_tray_id`` and change which spool is charged."""
@staticmethod
def _run(tracking, state, spool_by_tag, archive):
# The first SELECT fetches the tracking row; every later one fetches the
# archive for the colour / type rewrites (#1494, #2563).
rows = iter([tracking])
def _next_row(*_args, **_kwargs):
result = MagicMock()
result.scalar_one_or_none.return_value = next(rows, archive)
return result
db = AsyncMock()
db.execute = AsyncMock(side_effect=_next_row)
db.delete = AsyncMock()
db.commit = AsyncMock()
client = AsyncMock()
client.find_spool_by_tag = AsyncMock(side_effect=lambda tag: spool_by_tag.get(tag))
client.use_spool = AsyncMock()
pm = _patched_pm(state)
async def _go():
from backend.app.services.spoolman_tracking import report_usage
with (
patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
patch(
"backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
AsyncMock(return_value=client),
),
patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SER")),
patch(
"backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
AsyncMock(return_value=None),
),
patch("backend.app.services.printer_manager.printer_manager", pm),
):
await report_usage(printer_id=1, archive_id=42)
return _go, client
@pytest.mark.asyncio
async def test_mqtt_mapping_charges_the_tray_the_printer_named(self):
"""One-slot print whose filament actually came from AMS slot 4
(global tray 3). With no stored mapping the positional default charges
global tray 0 — the wrong spool, and the archive is then rewritten to
that spool's colour. The printer's mapping field says otherwise."""
tracking = SimpleNamespace(
filament_usage=[{"slot_id": 1, "used_g": 25.0, "type": "PLA", "color": "#FF0000"}],
ams_trays={
"0": {"tray_uuid": "TRAY0UUID", "tag_uid": "", "tray_type": "PLA"},
"3": {"tray_uuid": "TRAY3UUID", "tag_uid": "", "tray_type": "PLA"},
},
slot_to_tray=None,
tray_remain_start=None,
layer_usage=None,
filament_properties=None,
)
state = _state(mapping=[3])
spools = {
"TRAY0UUID": {"id": 100, "filament": {"color_hex": "FFFFFF", "material": "PLA"}},
"TRAY3UUID": {"id": 300, "filament": {"color_hex": "FF0000", "material": "PLA"}},
}
archive = SimpleNamespace(filament_color="#FF0000", filament_type="PLA")
run, client = self._run(tracking, state, spools, archive)
await run()
client.use_spool.assert_awaited_once_with(300, 25.0)
# And the visible half of the bug: the archive keeps the red it was
# printed in instead of being rewritten to the wrong spool's white.
assert archive.filament_color == "#FF0000"
@pytest.mark.asyncio
async def test_a_stored_mapping_is_never_second_guessed(self):
"""Print start captured the real ams_mapping (LAN print, or a Bambuddy
queue job). That is the slicer's own instruction and outranks anything
read back off the printer, whose mapping field may still describe an
earlier job."""
tracking = SimpleNamespace(
filament_usage=[{"slot_id": 1, "used_g": 25.0, "type": "PLA", "color": "#FF0000"}],
ams_trays={
"0": {"tray_uuid": "TRAY0UUID", "tag_uid": "", "tray_type": "PLA"},
"3": {"tray_uuid": "TRAY3UUID", "tag_uid": "", "tray_type": "PLA"},
},
slot_to_tray=[0],
tray_remain_start=None,
layer_usage=None,
filament_properties=None,
)
state = _state(mapping=[3])
spools = {
"TRAY0UUID": {"id": 100, "filament": {"color_hex": "FFFFFF", "material": "PLA"}},
"TRAY3UUID": {"id": 300, "filament": {"color_hex": "FF0000", "material": "PLA"}},
}
archive = SimpleNamespace(filament_color="#FF0000", filament_type="PLA")
run, client = self._run(tracking, state, spools, archive)
await run()
client.use_spool.assert_awaited_once_with(100, 25.0)
+3 -3
View File
@@ -3173,9 +3173,9 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"dependencies": {
"balanced-match": "^4.0.2"
+1 -1
View File
@@ -48,7 +48,7 @@
},
"overrides": {
"minimatch": "^10.2.1",
"brace-expansion": "^5.0.8",
"brace-expansion": "^5.0.9",
"js-yaml": "^4.3.0",
"react-router": "7.18.1"
},
@@ -0,0 +1,137 @@
/**
* The active-cycle badge on the AMS card (#2759).
*
* Bambu never echoes back which filament or temperature a drying cycle is
* running, so the backend hands us two independent fields: `dry_filament`,
* which it can also infer from a uniformly loaded unit, and `dry_target_temp`,
* which it only knows from the target it cached when sending the command. The
* temperature can therefore go missing while the filament survives, and the
* badge has to render that pairing rather than dropping both.
*
* The reporter's second AMS held only PLA and was drying at the 45°C they
* picked; with no cached target the badge previously showed the RFID
* recommendation and read "PLA @ 55°C".
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { render } from '../utils';
import { PrintersPage } from '../../pages/PrintersPage';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
const mockPrinter = {
id: 1,
name: 'X1C',
ip_address: '192.168.1.100',
serial_number: '01P00A000000001',
access_code: '12345678',
model: 'X1C',
enabled: true,
nozzle_diameter: 0.4,
nozzle_type: 'stainless_steel',
location: 'Workshop',
auto_archive: true,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
const baseTray = {
tray_color: 'FF0000FF',
tray_type: 'PLA',
tray_sub_brands: 'PLA Basic',
tray_id_name: 'A00-R0',
tray_info_idx: 'GFA00',
remain: 80,
k: 0.02,
cali_idx: null,
tag_uid: null,
tray_uuid: null,
nozzle_temp_min: 190,
nozzle_temp_max: 230,
drying_temp: 55,
drying_time: 8,
state: 3,
};
/** An AMS 2 Pro twelve hours into a cycle, with the badge fields under test. */
function makeStatus(target: { dry_filament: string | null; dry_target_temp: number | null }) {
return {
connected: true,
state: 'IDLE',
progress: 0,
layer_num: 0,
total_layers: 0,
temperatures: { nozzle: 25, bed: 25, chamber: 25 },
remaining_time: 0,
filename: null,
wifi_signal: -29,
speed_level: 2,
supports_drying: true,
drying_screen_only: false,
vt_tray: [],
ams: [
{
id: 0,
humidity: 30,
temp: 33,
is_ams_ht: false,
serial_number: 'AMS00',
sw_ver: '03.00.21.29',
dry_time: 719,
dry_status: 2,
dry_sub_status: 0,
dry_sf_reason: [],
module_type: 'n3f',
...target,
tray: [0, 1, 2, 3].map((id) => ({ id, ...baseTray })),
},
],
};
}
function renderWith(target: { dry_filament: string | null; dry_target_temp: number | null }) {
server.use(
http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
http.get('/api/v1/printers/:id/status', () => HttpResponse.json(makeStatus(target))),
http.get('/api/v1/queue/', () => HttpResponse.json([])),
);
render(<PrintersPage />);
}
describe('PrintersPage — AMS drying badge (#2759)', () => {
beforeEach(() => {
server.use(http.get('/api/v1/queue/', () => HttpResponse.json([])));
});
it('names the filament and the temperature when the cycle target is known', async () => {
renderWith({ dry_filament: 'PLA', dry_target_temp: 45 });
await waitFor(() => {
expect(screen.getAllByText('PLA @ 45°C').length).toBeGreaterThan(0);
});
});
it('still names the filament when only the temperature is unknown', async () => {
renderWith({ dry_filament: 'PLA', dry_target_temp: null });
// The filament survives on its own — dropping it too would leave the badge
// showing a bare countdown for a cycle we can still describe.
await waitFor(() => {
expect(screen.getAllByText('PLA').length).toBeGreaterThan(0);
});
// And it must not fall back to the trays' RFID recommendation (55°C here),
// which is what the user's chosen 45°C was being overwritten with. Scoped
// to the badge's own "<filament> @ <temp>°C" shape — the card carries
// unrelated nozzle and bed readings in °C.
expect(screen.queryByText(/@ \d+°C/)).toBeNull();
});
it('shows the countdown alone when the unit gives no evidence at all', async () => {
renderWith({ dry_filament: null, dry_target_temp: null });
await waitFor(() => {
expect(screen.getAllByText(/11h 59m/).length).toBeGreaterThan(0);
});
expect(screen.queryByText(/@ \d+°C/)).toBeNull();
});
});
+12 -4
View File
@@ -4911,9 +4911,15 @@ function PrinterCard({
<div className="flex items-center gap-2 rounded-lg bg-amber-50 dark:bg-amber-500/10 px-2 py-1 text-[length:var(--pc-t9,9px)]">
<Flame className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] text-amber-600 dark:text-amber-400 shrink-0" />
<span className="text-amber-700 dark:text-amber-400 font-medium">{t('printers.drying.active')}</span>
{ams.dry_filament && ams.dry_target_temp != null && (
{/* The temperature is only ever known from the target we
cached when sending the command — the filament can also
be read off a uniformly loaded unit, so it can outlive
the temperature (#2759). */}
{ams.dry_filament && (
<span className="text-amber-700/80 dark:text-amber-300/70">
{t('printers.drying.targetSummary', { filament: ams.dry_filament, temp: ams.dry_target_temp })}
{ams.dry_target_temp != null
? t('printers.drying.targetSummary', { filament: ams.dry_filament, temp: ams.dry_target_temp })
: ams.dry_filament}
</span>
)}
<span className="text-amber-700/80 dark:text-amber-300/70">
@@ -5453,9 +5459,11 @@ function PrinterCard({
{ams.dry_time > 0 && (
<div className="flex items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-lg bg-amber-50 dark:bg-amber-500/10 px-2 py-1 text-[length:var(--pc-t9,9px)]">
<Flame className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] text-amber-600 dark:text-amber-400 shrink-0" />
{ams.dry_filament && ams.dry_target_temp != null && (
{ams.dry_filament && (
<span className="text-amber-700/80 dark:text-amber-300/70 text-[length:var(--pc-t8,8px)] truncate">
{t('printers.drying.targetSummary', { filament: ams.dry_filament, temp: ams.dry_target_temp })}
{ams.dry_target_temp != null
? t('printers.drying.targetSummary', { filament: ams.dry_filament, temp: ams.dry_target_temp })
: ams.dry_filament}
</span>
)}
<span className="text-amber-700/80 dark:text-amber-300/70 text-[length:var(--pc-t8,8px)] truncate">
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BkwFYHam.js"></script>
<script type="module" crossorigin src="/assets/index-yKwaHTh1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B3jj6-fz.css">
</head>
<body>