mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
fix: a failed first run no longer leaves the archive at its partial cost (built-in inventory); completion stops the start cost estimate before writing the real cost; 2FA/SSO logins report LDAP/OIDC users' auth source; a fault in both print_error and HMS is listed once; a refused H2D load's tray target expires; refused K-profile re-applies are logged; the cloud filament-name map is fetched once per account without holding a DB connection; the library edit route refuses folder changes across the external boundary; a slow slice-progress answer is not reported as an unreachable sidecar
This commit is contained in:
@@ -164,6 +164,15 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
|
||||
|
||||
### Fixed
|
||||
- **A print that failed on its first run left the archive costing only the part it printed** — With the built-in inventory, the archive took the failed run's partial cost, so a reprint was estimated, reserved and billed from that fraction. The archive now keeps the cost of one full print (as in Spoolman mode); the failed run's own cost is still recorded in the Print Log.
|
||||
- **Spoolman: a very short print could end with its estimated cost instead of the real one** — The cost estimate made at print start could still be waiting on Spoolman when the print ended, and then wrote over the real cost. Completion now stops it first.
|
||||
- **After a 2FA or SSO login, LDAP and OIDC users were offered "Change Password"** — The login answered as if they were local users, until the page was reloaded.
|
||||
- **The same printer fault could be listed twice** — A fault the printer reported both as a print error and in its HMS list was not recognised as the same one.
|
||||
- **H2D: a refused manual filament load could show the wrong active tray later** — Its target slot was remembered until something cleared it, so a print loading the same slot number on another AMS much later was shown as that slot. It is now forgotten after two minutes.
|
||||
- **K-profiles: a printer refusing the re-applied profile after an RFID read was not in the logs** — Only the "applied" line was, which made wrong pressure-advance prints hard to diagnose.
|
||||
- **K-profiles: opening several pages at once could fetch the cloud filament names many times** — Each one fetched every custom preset from Bambu Cloud while holding a database connection; they now share one fetch.
|
||||
- **Library API: changing a file's folder could move it into or out of an external folder without moving the file** — The file then showed in a folder it was not in. That change is now refused with a pointer to the move endpoint, which moves the file itself.
|
||||
- **Slicing: a slow progress answer was reported as "Slicer sidecar unreachable"** — It now says the sidecar did not answer in time.
|
||||
- **Printer card: the cooling/heating mode switched on the printer showed the old mode** — The live updates did not carry it, so the card caught up only with its next refresh.
|
||||
- **Printer card: an H2C rack slot's material label disappeared** — The live updates replaced the rack without each slot's filament type, so the label fell back to the filament ID or went blank until the next refresh.
|
||||
- **Git backup: a print running when the backup was made came back as a failed print** — Restored as still printing, the archive was closed on the next printer reconnect as if the print had just failed, with a failure notification and a Print Log entry. It is now restored as cancelled ("No status update received"), the way Bambuddy closes a print it lost track of; an archive this instance still has keeps its own status on overwrite.
|
||||
|
||||
@@ -15,6 +15,7 @@ from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core import database
|
||||
from backend.app.core.auth import (
|
||||
RequirePermissionIfAuthEnabled,
|
||||
_user_from_api_key,
|
||||
@@ -24,6 +25,7 @@ from backend.app.core.auth import (
|
||||
)
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.core.tasks import spawn_background_task
|
||||
from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
@@ -184,6 +186,8 @@ router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud
|
||||
def _forget_account_presets(account: int | None) -> None:
|
||||
"""Drop what was read with an account's cloud token, once that token changes."""
|
||||
_filament_id_name_cache.pop(account, None)
|
||||
# A build still running reads with the old token; it must not be joined.
|
||||
_filament_id_map_inflight.pop(account, None)
|
||||
for key in [key for key in _filament_cache if key[0] == account]:
|
||||
del _filament_cache[key]
|
||||
|
||||
@@ -191,6 +195,7 @@ def _forget_account_presets(account: int | None) -> None:
|
||||
def forget_all_cloud_presets() -> None:
|
||||
"""Drop every account's cached presets (the user table was replaced)."""
|
||||
_filament_id_name_cache.clear()
|
||||
_filament_id_map_inflight.clear()
|
||||
_filament_cache.clear()
|
||||
|
||||
|
||||
@@ -1303,11 +1308,12 @@ async def get_builtin_filaments(
|
||||
# cloud account: the user whose token read it, or None for the global token
|
||||
# used when auth is off. Value: (time read, mapping).
|
||||
_filament_id_name_cache: dict[int | None, tuple[float, dict[str, str]]] = {}
|
||||
# Builds still running, by account (RC-C11).
|
||||
_filament_id_map_inflight: dict[int | None, asyncio.Future] = {}
|
||||
|
||||
|
||||
@router.get("/filament-id-map")
|
||||
async def get_filament_id_map(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = cloud_caller(Permission.FILAMENTS_READ),
|
||||
):
|
||||
"""
|
||||
@@ -1326,7 +1332,36 @@ async def get_filament_id_map(
|
||||
if cached and time.time() - cached[0] < FILAMENT_CACHE_TTL:
|
||||
return cached[1]
|
||||
|
||||
cloud = await build_authenticated_cloud(db, current_user)
|
||||
# One build per account at a time: when the cache expired, every K-profile
|
||||
# view asked at once and each ran the full 1 + N fetch (RC-C11). Registered
|
||||
# before anything is awaited, so callers arriving together all join it.
|
||||
running = _filament_id_map_inflight.get(account)
|
||||
if running is None or running.done():
|
||||
running = spawn_background_task(_build_filament_id_map(current_user, account), name="cloud-filament-id-map")
|
||||
_filament_id_map_inflight[account] = running
|
||||
|
||||
def _forget(done: asyncio.Future, account: int | None = account) -> None:
|
||||
if _filament_id_map_inflight.get(account) is done:
|
||||
del _filament_id_map_inflight[account]
|
||||
|
||||
running.add_done_callback(_forget)
|
||||
try:
|
||||
# shield: a caller that goes away must not cancel the build others joined.
|
||||
return dict(await asyncio.shield(running))
|
||||
except Exception:
|
||||
return cached[1] if cached else {}
|
||||
|
||||
|
||||
async def _build_filament_id_map(user: User | None, account: int | None) -> dict[str, str]:
|
||||
"""Fetch an account's custom presets and map their filament ids to names.
|
||||
|
||||
Reads the stored token in a session of its own, closed before the Bambu
|
||||
Cloud round-trips: no request holds a database connection across them.
|
||||
"""
|
||||
import time
|
||||
|
||||
async with database.async_session() as db:
|
||||
cloud = await build_authenticated_cloud(db, user)
|
||||
if cloud is None or not cloud.is_authenticated:
|
||||
if cloud is not None:
|
||||
await cloud.close()
|
||||
@@ -1355,10 +1390,11 @@ async def get_filament_id_map(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_filament_id_name_cache[account] = (time.time(), result)
|
||||
# Not when the account signed out meanwhile: its build was dropped,
|
||||
# and this map was read with the token that is gone.
|
||||
if _filament_id_map_inflight.get(account) is asyncio.current_task():
|
||||
_filament_id_name_cache[account] = (time.time(), result)
|
||||
return result
|
||||
except Exception:
|
||||
return cached[1] if cached else {}
|
||||
finally:
|
||||
await cloud.close()
|
||||
|
||||
|
||||
@@ -5483,12 +5483,21 @@ async def update_file(
|
||||
# and _without_print_name strips the embedded 3MF Title on import (#1489).
|
||||
|
||||
if data.folder_id is not None:
|
||||
if data.folder_id == 0:
|
||||
file.folder_id = None
|
||||
else:
|
||||
target_folder = None
|
||||
if data.folder_id != 0:
|
||||
# library:update_all moves into any folder, as before (#3201).
|
||||
await get_writable_folder(db, data.folder_id, None if can_modify_all else user)
|
||||
file.folder_id = data.folder_id
|
||||
target_folder = await get_writable_folder(db, data.folder_id, None if can_modify_all else user)
|
||||
# Into or out of an external folder the bytes have to move too, and a
|
||||
# read-only one refuses them: that is the move route's work. Here only
|
||||
# folder_id changed, so the file showed in a folder its bytes were not
|
||||
# in (F8).
|
||||
unchanged = (target_folder.id if target_folder is not None else None) == file.folder_id
|
||||
if not unchanged and (file.is_external or (target_folder is not None and target_folder.is_external)):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Move a file into or out of an external folder with POST /api/v1/library/files/move",
|
||||
)
|
||||
file.folder_id = target_folder.id if target_folder is not None else None
|
||||
|
||||
if data.project_id is not None:
|
||||
if data.project_id == 0:
|
||||
|
||||
@@ -68,7 +68,6 @@ from backend.app.schemas.auth import (
|
||||
EmailOTPDisableRequest,
|
||||
EmailOTPEnableConfirmRequest,
|
||||
EmailOTPSendRequest,
|
||||
GroupBrief,
|
||||
LoginResponse,
|
||||
OIDCAuthorizeResponse,
|
||||
OIDCExchangeRequest,
|
||||
@@ -197,17 +196,12 @@ router = APIRouter(prefix="/auth", tags=["2fa", "oidc"])
|
||||
# Helper: user response
|
||||
# ---------------------------------------------------------------------------
|
||||
def _user_to_response(user: User) -> UserResponse:
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
is_admin=user.is_admin,
|
||||
groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
|
||||
permissions=sorted(user.get_permissions()),
|
||||
created_at=user.created_at.isoformat(),
|
||||
)
|
||||
# The auth routes' builder, not a copy: the copy here left out
|
||||
# ``auth_source``, so a 2FA or OIDC login reported LDAP and OIDC users as
|
||||
# local and the page offered them "Change Password" (PC-C9).
|
||||
from backend.app.api.routes.auth import _user_to_response as build
|
||||
|
||||
return build(user)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -848,6 +848,10 @@ async def get_preview_slice_progress(
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(url)
|
||||
except httpx.TimeoutException:
|
||||
# Busy, not gone: a heavy slice can hold the progress answer past 5 s
|
||||
# (TO-C14). Still 503, so the poller keeps trying.
|
||||
raise HTTPException(status_code=503, detail="Slicer sidecar did not answer in time") from None
|
||||
except httpx.RequestError:
|
||||
# Sidecar unreachable: surface as 503 instead of 500 so the
|
||||
# frontend's poller can keep trying without flagging a hard error.
|
||||
|
||||
@@ -107,6 +107,7 @@ from backend.app.services.ams_assignment_lock import get_ams_assignment_lock
|
||||
from backend.app.services.archive import ArchiveService, peek_plate_index_in_3mf, swap_plate_suffix
|
||||
from backend.app.services.archive_cost_estimate import (
|
||||
schedule_archive_cost_estimate as _schedule_archive_cost_estimate,
|
||||
settle_archive_cost_estimate as _settle_archive_cost_estimate,
|
||||
)
|
||||
from backend.app.services.archive_purge import archive_purge_service
|
||||
from backend.app.services.bambu_ftp import (
|
||||
@@ -8435,6 +8436,10 @@ async def _close_out_print(printer_id: int, data: dict):
|
||||
if archive_id:
|
||||
notify_plate_id = _print_plate_ids.pop(archive_id, None)
|
||||
|
||||
# The start estimate must not outlive the real cost written below (COST-4).
|
||||
if archive_id:
|
||||
await _settle_archive_cost_estimate(archive_id)
|
||||
|
||||
# Internal inventory: track AMS remain% deltas (skip if Spoolman handles usage)
|
||||
try:
|
||||
async with async_session() as db:
|
||||
|
||||
@@ -26,6 +26,7 @@ Strictly best-effort. Nothing here may fail a print start.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
|
||||
@@ -183,10 +184,38 @@ def schedule_archive_cost_estimate(
|
||||
await estimate_archive_cost_at_start(db, printer_id, archive_id, printer_manager, mapping, plate_id)
|
||||
|
||||
try:
|
||||
return spawn_background_task(_run(), name=f"archive-cost-estimate-{archive_id}")
|
||||
task = spawn_background_task(_run(), name=f"archive-cost-estimate-{archive_id}")
|
||||
except Exception: # noqa: BLE001 -- an estimate must never fail a print start
|
||||
logger.warning("[COST] Archive %s: could not schedule the cost estimate", archive_id, exc_info=True)
|
||||
return None
|
||||
_running_estimates[archive_id] = task
|
||||
|
||||
def _forget(done: asyncio.Task, archive_id: int = archive_id) -> None:
|
||||
if _running_estimates.get(archive_id) is done:
|
||||
del _running_estimates[archive_id]
|
||||
|
||||
task.add_done_callback(_forget)
|
||||
return task
|
||||
|
||||
|
||||
# Estimates still running, by archive: completion stops its archive's before
|
||||
# writing the real cost (COST-4).
|
||||
_running_estimates: dict[int, asyncio.Task] = {}
|
||||
|
||||
|
||||
async def settle_archive_cost_estimate(archive_id: int) -> None:
|
||||
"""Stop the archive's start estimate, if still running, before the real cost.
|
||||
|
||||
Completion commits the measured cost before it marks the archive ended; an
|
||||
estimate still waiting on Spoolman then saw a printing archive with no runs
|
||||
logged, and wrote its figure over the real one on a very short print.
|
||||
"""
|
||||
task = _running_estimates.pop(archive_id, None)
|
||||
if task is None or task.done():
|
||||
return
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await task
|
||||
|
||||
|
||||
async def estimate_archive_cost_at_start(
|
||||
|
||||
@@ -29,6 +29,11 @@ from backend.app.utils.paho_teardown import retire_paho_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# How long a manual load's tray target stays a hint for an H2D's tray_now: long
|
||||
# enough for a real load to report, short enough that a refused one cannot
|
||||
# relabel a later print's load of the same slot number (FW-C7).
|
||||
PENDING_TRAY_TARGET_TTL = 120.0
|
||||
|
||||
# AMS module name prefixes used in get_version responses.
|
||||
# The numeric suffix after '/' is the AMS unit ID as reported in push_status.
|
||||
# "ams/<id>" – original AMS (X1C, X1E, P1S, …)
|
||||
@@ -1043,6 +1048,8 @@ class PrinterState:
|
||||
last_loaded_tray: int = -1
|
||||
# Pending load target - used to track what tray we're loading for H2D disambiguation
|
||||
pending_tray_target: int | None = None
|
||||
# When it was set (time.monotonic): see PENDING_TRAY_TARGET_TTL.
|
||||
pending_tray_target_at: float | None = None
|
||||
# AMS status for filament change tracking (from print.ams.ams_status field)
|
||||
# ams_status is a combined value: lower 8 bits = sub status, bits 8-15 = main status
|
||||
# Main status: 0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration, etc.
|
||||
@@ -2586,15 +2593,16 @@ class BambuMQTTClient:
|
||||
self._dev_mode_probe_seq is not None
|
||||
and print_data.get("sequence_id") == self._dev_mode_probe_seq
|
||||
)
|
||||
if (
|
||||
cmd == "ams_filament_setting"
|
||||
and not is_dev_mode_probe
|
||||
and isinstance(result, str)
|
||||
and result.lower() != "success"
|
||||
):
|
||||
# extrusion_cali_sel too (FW-C3): every RFID re-read
|
||||
# re-applies the spool's K-profile and logs "Applied PA
|
||||
# profile", so its refusal must reach the bundle beside it.
|
||||
# The printer's word, not a verdict: the extrusion_cali_*
|
||||
# acks are known to say "fail" for writes that applied.
|
||||
if not is_dev_mode_probe and isinstance(result, str) and result.lower() != "success":
|
||||
logger.info(
|
||||
"[%s] ams_filament_setting refused: result=%s reason=%s ams_id=%s tray_id=%s",
|
||||
"[%s] %s refused: result=%s reason=%s ams_id=%s tray_id=%s",
|
||||
self.serial_number,
|
||||
cmd,
|
||||
result,
|
||||
print_data.get("reason", ""),
|
||||
print_data.get("ams_id"),
|
||||
@@ -3377,6 +3385,19 @@ class BambuMQTTClient:
|
||||
if self._is_dual_nozzle and 0 <= parsed_tray_now <= 3:
|
||||
# First, check if we have a pending target that matches this slot
|
||||
pending_target = self.state.pending_tray_target
|
||||
pending_at = self.state.pending_tray_target_at
|
||||
if (
|
||||
pending_target is not None
|
||||
and pending_at is not None
|
||||
and time.monotonic() - pending_at > PENDING_TRAY_TARGET_TTL
|
||||
):
|
||||
# A load the printer refused or dropped: nothing clears
|
||||
# it (tray_now stays 255), and a print loading the same
|
||||
# slot number on another AMS long after was shown as
|
||||
# this target (FW-C7). Fall through to the snow field.
|
||||
self.state.pending_tray_target = None
|
||||
self.state.pending_tray_target_at = None
|
||||
pending_target = None
|
||||
if pending_target is not None:
|
||||
# Same slot rule as the snow branch below: an AMS-HT
|
||||
# (128-135) has its one tray in slot 0.
|
||||
@@ -5247,7 +5268,10 @@ class BambuMQTTClient:
|
||||
for e in self.state.hms_errors:
|
||||
# Extract short code from existing errors
|
||||
e_module = (e.attr >> 16) & 0xFFFF
|
||||
e_error = int(e.code.replace("0x", ""), 16) if e.code else 0
|
||||
# The low half only: an hms[] code carries its alert
|
||||
# level above it (0x18061 for 0500_8061), and read
|
||||
# whole it never matched, listing the fault twice (M-U9).
|
||||
e_error = (int(e.code.replace("0x", ""), 16) & 0xFFFF) if e.code else 0
|
||||
existing_short_codes.add(f"{e_module:04X}_{e_error:04X}")
|
||||
|
||||
if short_code not in existing_short_codes:
|
||||
@@ -7859,6 +7883,7 @@ class BambuMQTTClient:
|
||||
# Track this load request for H2D dual-nozzle disambiguation
|
||||
# H2D reports only slot number (0-3) in tray_now, so we use our tracked value
|
||||
self.state.pending_tray_target = tray_id
|
||||
self.state.pending_tray_target_at = time.monotonic()
|
||||
logger.info("[%s] Set pending_tray_target=%s for H2D disambiguation", self.serial_number, tray_id)
|
||||
|
||||
return True
|
||||
@@ -7965,6 +7990,7 @@ class BambuMQTTClient:
|
||||
|
||||
# Clear tracked load request since we're unloading
|
||||
self.state.pending_tray_target = None
|
||||
self.state.pending_tray_target_at = None
|
||||
logger.info("[%s] Cleared pending_tray_target (unload)", self.serial_number)
|
||||
|
||||
return True
|
||||
|
||||
@@ -1070,7 +1070,12 @@ async def on_print_complete(
|
||||
# 100 g/$X print with a 10 g/$X/10 partial (#1378).
|
||||
from backend.app.services.print_log import other_runs_of
|
||||
|
||||
if not await other_runs_of(db, archive):
|
||||
# Nor from a run that did not finish: the archive's cost is one
|
||||
# full print of the file -- the queue estimate, plate scaling and
|
||||
# billing read it so -- and a failed run's partial cost already
|
||||
# lands on its own Print Log entry. Spoolman mode never wrote it
|
||||
# either (COST-3).
|
||||
if status == "completed" and not await other_runs_of(db, archive):
|
||||
archive.cost = round(total_cost, 2)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -308,6 +308,60 @@ class TestLibraryFilesAPI:
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == other_file.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("direction", ["into", "out of"])
|
||||
async def test_an_edit_does_not_move_a_file_across_the_external_boundary(
|
||||
self, async_client: AsyncClient, folder_factory, file_factory, db_session, direction
|
||||
):
|
||||
"""F8: the edit route only changed folder_id -- no byte move, no
|
||||
read-only check -- so the file showed in a folder its bytes are not
|
||||
in. Such a move goes through POST /library/files/move."""
|
||||
internal = await folder_factory(name="Mine")
|
||||
nas = await folder_factory(name="NAS", is_external=True, external_path="/mnt/nas")
|
||||
if direction == "into":
|
||||
file = await file_factory(folder_id=internal.id, filename="a.3mf", is_external=False)
|
||||
target = nas.id
|
||||
else:
|
||||
file = await file_factory(folder_id=nas.id, filename="a.3mf", is_external=True)
|
||||
target = internal.id
|
||||
|
||||
response = await async_client.put(f"/api/v1/library/files/{file.id}", json={"folder_id": target})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "/library/files/move" in response.json()["detail"]
|
||||
await db_session.refresh(file)
|
||||
assert file.folder_id != target
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_an_external_file_sent_with_its_own_folder_is_no_move(
|
||||
self, async_client: AsyncClient, folder_factory, file_factory
|
||||
):
|
||||
nas = await folder_factory(name="NAS", is_external=True, external_path="/mnt/nas")
|
||||
file = await file_factory(folder_id=nas.id, filename="a.3mf", is_external=True)
|
||||
|
||||
response = await async_client.put(
|
||||
f"/api/v1/library/files/{file.id}", json={"folder_id": nas.id, "notes": "kept"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_an_edit_still_moves_a_file_between_its_own_folders(
|
||||
self, async_client: AsyncClient, folder_factory, file_factory, db_session
|
||||
):
|
||||
first = await folder_factory(name="One")
|
||||
second = await folder_factory(name="Two")
|
||||
file = await file_factory(folder_id=first.id, filename="a.3mf", is_external=False)
|
||||
|
||||
response = await async_client.put(f"/api/v1/library/files/{file.id}", json={"folder_id": second.id})
|
||||
|
||||
assert response.status_code == 200
|
||||
await db_session.refresh(file)
|
||||
assert file.folder_id == second.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_list_files_internal_only(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
|
||||
|
||||
@@ -164,6 +164,14 @@ async def test_a_completion_reconciled_at_reconnect_has_no_actual_duration(test_
|
||||
assert archive_data["actual_time_seconds"] is None
|
||||
|
||||
|
||||
async def test_completion_stops_the_start_estimate_before_writing_the_cost(test_engine, db_session, queued_print):
|
||||
"""COST-4: the background estimate must not outlive the real cost."""
|
||||
with patch("backend.app.main._settle_archive_cost_estimate", new_callable=AsyncMock) as settle:
|
||||
await _complete(test_engine, queued_print["printer"], "completed")
|
||||
|
||||
settle.assert_awaited_once_with(queued_print["archive"])
|
||||
|
||||
|
||||
async def test_a_failed_print(test_engine, db_session, queued_print):
|
||||
await _complete(test_engine, queued_print["printer"], "failed")
|
||||
|
||||
|
||||
@@ -377,3 +377,41 @@ class TestRaceWithCompletion:
|
||||
await db_session.refresh(archive)
|
||||
assert archive.cost == 1.23
|
||||
ws.assert_not_awaited()
|
||||
|
||||
|
||||
class TestCompletionStopsTheEstimate:
|
||||
@pytest.mark.asyncio
|
||||
async def test_settling_cancels_a_running_estimate_before_it_writes(self):
|
||||
"""COST-4: completion commits the real cost before it marks the archive
|
||||
ended, so an estimate still waiting on Spoolman saw "printing" and wrote
|
||||
over it. Completion now stops the estimate first."""
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
wrote: list[int] = []
|
||||
blocked = asyncio.Event()
|
||||
|
||||
async def slow_estimate(*_args):
|
||||
await blocked.wait()
|
||||
wrote.append(1)
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_session():
|
||||
yield object()
|
||||
|
||||
with (
|
||||
patch("backend.app.core.database.async_session", new=fake_session),
|
||||
patch.object(archive_cost_estimate, "estimate_archive_cost_at_start", new=slow_estimate),
|
||||
):
|
||||
task = archive_cost_estimate.schedule_archive_cost_estimate(3, 42, "pm")
|
||||
await asyncio.sleep(0)
|
||||
await archive_cost_estimate.settle_archive_cost_estimate(42)
|
||||
blocked.set()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert task.cancelled()
|
||||
assert wrote == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settling_with_nothing_running_is_harmless(self):
|
||||
await archive_cost_estimate.settle_archive_cost_estimate(9999)
|
||||
|
||||
@@ -3041,6 +3041,19 @@ class TestTrayNowDualNozzleH2DPendingTarget(_H2DFixtureMixin):
|
||||
assert h2d_client.state.tray_now == 5
|
||||
assert h2d_client.state.pending_tray_target is None # cleared
|
||||
|
||||
def test_a_pending_target_from_a_load_long_ago_is_not_used(self, h2d_client):
|
||||
"""FW-C7: a manual load the printer refused leaves its target pending
|
||||
(tray_now stays 255, which keeps it). A print loading the same slot
|
||||
number on another AMS much later was shown as that old target."""
|
||||
import time
|
||||
|
||||
h2d_client.state.pending_tray_target = 5
|
||||
h2d_client.state.pending_tray_target_at = time.monotonic() - 600
|
||||
h2d_client._process_message(_ams_payload(1))
|
||||
|
||||
assert h2d_client.state.tray_now != 5
|
||||
assert h2d_client.state.pending_tray_target is None
|
||||
|
||||
def test_pending_target_slot_mismatch(self, h2d_client):
|
||||
"""pending=5, tray_now='2' → uses raw slot, clears pending."""
|
||||
h2d_client.state.pending_tray_target = 5
|
||||
@@ -5545,6 +5558,14 @@ class TestHMSUserActionFiltering:
|
||||
assert len(mqtt_client.state.hms_errors) == 1
|
||||
assert mqtt_client.state.hms_errors[0].code == "0x8061"
|
||||
|
||||
def test_a_fault_in_both_hms_and_print_error_is_listed_once(self, mqtt_client):
|
||||
"""M-U9: an hms[] code carries its alert level in the high half
|
||||
(0x00018061); the dedupe read it unmasked, so it never matched
|
||||
print_error's 0500_8061 and the card listed the fault twice."""
|
||||
mqtt_client._update_state({"hms": [{"attr": 0x05000100, "code": 0x00018061}], "print_error": 0x0500_8061})
|
||||
|
||||
assert len(mqtt_client.state.hms_errors) == 1
|
||||
|
||||
|
||||
class TestHMSFullCode:
|
||||
"""full_code is the firmware-matching key for HMS-related commands.
|
||||
@@ -8474,6 +8495,29 @@ class TestAmsFilamentSettingRefusalLogging:
|
||||
assert "ams_id=0" in refusals[0]
|
||||
assert "tray_id=1" in refusals[0]
|
||||
|
||||
def test_a_refused_k_profile_selection_is_logged_too(self, mqtt_client, caplog):
|
||||
"""FW-C3: an RFID re-read re-applies the spool's K-profile and logs
|
||||
"Applied PA profile"; the printer's refusal of it stayed at DEBUG, so
|
||||
a support bundle showed only the success."""
|
||||
caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
|
||||
|
||||
mqtt_client._process_message(
|
||||
{
|
||||
"print": {
|
||||
"command": "extrusion_cali_sel",
|
||||
"result": "fail",
|
||||
"reason": "cali_idx not found",
|
||||
"ams_id": 0,
|
||||
"tray_id": 2,
|
||||
"sequence_id": "0",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
lines = [line for line in caplog.text.splitlines() if "extrusion_cali_sel refused" in line]
|
||||
assert len(lines) == 1
|
||||
assert "cali_idx not found" in lines[0]
|
||||
|
||||
def test_success_stays_quiet(self, mqtt_client, caplog):
|
||||
caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
|
||||
|
||||
@@ -8530,16 +8574,6 @@ class TestAmsFilamentSettingRefusalLogging:
|
||||
|
||||
assert len(self._refusals(caplog)) == 1
|
||||
|
||||
def test_extrusion_cali_sel_is_untouched(self, mqtt_client, caplog):
|
||||
"""The sibling in the same branch keeps its DEBUG-only handling; this
|
||||
change is scoped to the write #2756 is about."""
|
||||
caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
|
||||
|
||||
mqtt_client._process_message({"print": {"command": "extrusion_cali_sel", "result": "fail", "sequence_id": "0"}})
|
||||
|
||||
assert self._refusals(caplog) == []
|
||||
assert "extrusion_cali_sel" not in caplog.text
|
||||
|
||||
|
||||
class TestDryingCycleEndReport:
|
||||
"""#2863 — the cycle-end report carries what was seen of the cycle, so the
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""The cloud filament-ID map is built once per account at a time, without a
|
||||
request holding a database connection (RC-C11).
|
||||
|
||||
When the 5-minute cache expired, every K-profile view asked at once and each
|
||||
ran the full 1 + N Bambu Cloud fetch while its request held a database
|
||||
connection idle in transaction.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.api.routes import cloud
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _empty():
|
||||
cloud.forget_all_cloud_presets()
|
||||
yield
|
||||
cloud.forget_all_cloud_presets()
|
||||
|
||||
|
||||
def _slow_cloud(release: asyncio.Event):
|
||||
async def settings():
|
||||
await release.wait()
|
||||
return {"filament": {"private": [{"setting_id": "S1"}]}}
|
||||
|
||||
return SimpleNamespace(
|
||||
is_authenticated=True,
|
||||
get_slicer_settings=AsyncMock(side_effect=settings),
|
||||
get_setting_detail=AsyncMock(return_value={"filament_id": "P1", "name": "Mine @Bambu Lab X1C"}),
|
||||
close=AsyncMock(),
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _own_session():
|
||||
yield "own session"
|
||||
|
||||
|
||||
async def test_views_arriving_together_share_one_build():
|
||||
release = asyncio.Event()
|
||||
built: list = []
|
||||
|
||||
async def build(db, user):
|
||||
# A real token read awaits the database.
|
||||
await asyncio.sleep(0.001)
|
||||
built.append(db)
|
||||
return _slow_cloud(release)
|
||||
|
||||
user = SimpleNamespace(id=7)
|
||||
with (
|
||||
patch.object(cloud, "build_authenticated_cloud", new=build),
|
||||
patch("backend.app.core.database.async_session", new=_own_session),
|
||||
):
|
||||
calls = [asyncio.create_task(cloud.get_filament_id_map(current_user=user)) for _ in range(3)]
|
||||
await asyncio.sleep(0.05)
|
||||
release.set()
|
||||
results = await asyncio.gather(*calls)
|
||||
|
||||
assert results == [{"P1": "Mine"}] * 3
|
||||
assert built == ["own session"], "every view built the map itself, or in its request's session"
|
||||
|
||||
|
||||
async def test_a_build_running_when_the_account_signs_out_is_not_cached():
|
||||
release = asyncio.Event()
|
||||
|
||||
async def build(db, user):
|
||||
return _slow_cloud(release)
|
||||
|
||||
with (
|
||||
patch.object(cloud, "build_authenticated_cloud", new=build),
|
||||
patch("backend.app.core.database.async_session", new=_own_session),
|
||||
):
|
||||
call = asyncio.create_task(cloud.get_filament_id_map(current_user=SimpleNamespace(id=7)))
|
||||
await asyncio.sleep(0.05)
|
||||
cloud._forget_account_presets(7)
|
||||
release.set()
|
||||
await call
|
||||
|
||||
assert 7 not in cloud._filament_id_name_cache
|
||||
@@ -443,6 +443,72 @@ class TestCostCalculation:
|
||||
# Cost = 20g / 1000 * 20.0 = 0.40
|
||||
assert results[0]["cost"] == 0.40
|
||||
|
||||
async def test_a_failed_first_run_leaves_the_full_print_cost_on_the_archive(self):
|
||||
"""COST-3: an archive's cost is one full print of the file -- what the
|
||||
queue estimate, plate scaling and billing read it as. A run that failed
|
||||
half way charged its partial cost to its Print Log entry, and also
|
||||
wrote it onto the archive, so a reprint was estimated and billed from
|
||||
half a print. Spoolman mode never did."""
|
||||
spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=20.0)
|
||||
assignment = _make_assignment(spool_id=1)
|
||||
archive = _make_archive(archive_id=10)
|
||||
archive.cost = 5.0 # the full-print estimate from print start
|
||||
|
||||
_active_sessions[1] = PrintSession(
|
||||
printer_id=1,
|
||||
print_name="Test",
|
||||
started_at=datetime.now(timezone.utc),
|
||||
tray_remain_start={(0, 0): 80},
|
||||
tray_now_at_start=0,
|
||||
)
|
||||
|
||||
# Failed at 50% progress
|
||||
printer_manager = MagicMock()
|
||||
printer_manager.get_status.return_value = SimpleNamespace(
|
||||
raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
|
||||
progress=50,
|
||||
layer_num=25,
|
||||
tray_now=0,
|
||||
)
|
||||
|
||||
# db returns: archive, queue_item(None), assignment, spool
|
||||
# ... and the archive again for the cost it carries
|
||||
db = _mock_db_sequential([archive, None, assignment, spool, archive])
|
||||
|
||||
# 40g total, but only 50% used
|
||||
filament_usage = [{"slot_id": 1, "used_g": 40.0, "type": "PLA", "color": "#FF0000"}]
|
||||
|
||||
with (
|
||||
# Its first run: the archive's cost is the one it would take.
|
||||
patch("backend.app.services.print_log.other_runs_of", AsyncMock(return_value=[])),
|
||||
patch("backend.app.core.config.settings") as mock_settings,
|
||||
patch("backend.app.api.routes.settings.get_setting", return_value="15.0"),
|
||||
patch(
|
||||
"backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
|
||||
return_value=filament_usage,
|
||||
),
|
||||
patch(
|
||||
"backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
|
||||
return_value=None, # No layer data, use linear scaling
|
||||
),
|
||||
):
|
||||
mock_settings.base_dir = MagicMock()
|
||||
mock_path = MagicMock()
|
||||
mock_path.exists.return_value = True
|
||||
mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
|
||||
|
||||
results = await on_print_complete(
|
||||
printer_id=1,
|
||||
data={"status": "failed", "last_progress": 50.0},
|
||||
printer_manager=printer_manager,
|
||||
db=db,
|
||||
archive_id=10,
|
||||
)
|
||||
|
||||
# The run's own cost is still measured (it goes to its Print Log entry).
|
||||
assert results[0]["cost"] == 0.40
|
||||
assert archive.cost == 5.0
|
||||
|
||||
async def _failed_after_firmware_reset(self, *, last_progress=0.0, last_layer_num=0, layer_usage=None):
|
||||
"""A failed print whose printer already reset progress and layer to 0."""
|
||||
spool = _make_spool(spool_id=1, label_weight=1000, cost_per_kg=20.0)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""A 2FA or SSO login reports the user's real auth source (PC-C9).
|
||||
|
||||
mfa.py kept its own copy of the user-response builder, and the copy left out
|
||||
``auth_source``: after a 2FA or OIDC login the schema default "local" reached
|
||||
the page, which then offered an LDAP or OIDC user "Change Password".
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.api.routes import mfa
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", ["ldap", "oidc", "local"])
|
||||
def test_the_login_response_carries_the_auth_source(source):
|
||||
user = SimpleNamespace(
|
||||
id=3,
|
||||
username="ana",
|
||||
email=None,
|
||||
role="user",
|
||||
is_active=True,
|
||||
is_admin=False,
|
||||
auth_source=source,
|
||||
groups=[],
|
||||
get_permissions=lambda: set(),
|
||||
created_at=datetime(2026, 10, 1),
|
||||
)
|
||||
|
||||
assert mfa._user_to_response(user).auth_source == source
|
||||
@@ -0,0 +1,44 @@
|
||||
"""A slow slice-progress answer is reported as slow, not as an unreachable sidecar (TO-C14).
|
||||
|
||||
A heavy slice can keep the sidecar from answering its progress poll within
|
||||
5 s; httpx's ReadTimeout is a RequestError, so the proxy said "Slicer sidecar
|
||||
unreachable". The poller retries on 503 either way; the text was wrong.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from backend.app.api.routes import slicer_presets
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def _poll(error: Exception) -> HTTPException:
|
||||
client = MagicMock()
|
||||
client.__aenter__ = AsyncMock(return_value=client)
|
||||
client.__aexit__ = AsyncMock(return_value=False)
|
||||
client.get = AsyncMock(side_effect=error)
|
||||
with (
|
||||
patch.object(slicer_presets, "_resolve_slicer_api_url", AsyncMock(return_value="http://sidecar:3000")),
|
||||
patch("httpx.AsyncClient", return_value=client),
|
||||
pytest.raises(HTTPException) as raised,
|
||||
):
|
||||
await slicer_presets.get_preview_slice_progress(request_id="abc", db=MagicMock(), _=(None, True))
|
||||
return raised.value
|
||||
|
||||
|
||||
async def test_a_timeout_says_the_sidecar_is_busy():
|
||||
refused = await _poll(httpx.ReadTimeout("read timed out"))
|
||||
|
||||
assert refused.status_code == 503
|
||||
assert "unreachable" not in refused.detail
|
||||
assert "did not answer in time" in refused.detail
|
||||
|
||||
|
||||
async def test_a_refused_connection_is_still_unreachable():
|
||||
refused = await _poll(httpx.ConnectError("connection refused"))
|
||||
|
||||
assert (refused.status_code, refused.detail) == (503, "Slicer sidecar unreachable")
|
||||
Reference in New Issue
Block a user