Merge branch '0.2.1b' into feature/cost_tracker

This commit is contained in:
Keybored
2026-02-19 16:25:27 +01:00
committed by GitHub
11 changed files with 741 additions and 75 deletions
+3
View File
@@ -21,6 +21,8 @@ All notable changes to Bambuddy will be documented in this file.
- **Print Queue Shows UUID Hash Instead of Filename** ([#438](https://github.com/maziggy/bambuddy/issues/438)) — When printing a library file, the Print Queue and archive displayed the UUID-hex disk filename (e.g., `c65887535303404eba1525176a0f78dc`) instead of the original human-readable name. Library files are stored on disk with UUID filenames for uniqueness, but `archive_print()` used the disk path as the display name. Now passes the original `LibraryFile.filename` through to `archive_print()` from both the print scheduler and the direct-print-from-library flow, so the archive's `filename`, `print_name`, and directory name all use the human-readable name.
- **Usage Tracking Wrong Spool on Dual-Nozzle / Multi-AMS Printers** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2C, H2D Pro, and other dual-nozzle printers with multiple AMS units, the usage tracker attributed filament consumption to the wrong spools. The MQTT `mapping` field — a per-print array that maps slicer filament slots to physical AMS trays — was preserved in state but never parsed or used. The tracker fell back to `slot_id - 1` as the global tray ID, which is incorrect when AMS hardware IDs differ from sequential indices (e.g., AMS-HT units with ID 128). Now decodes the MQTT mapping field from its snow encoding (`ams_hw_id * 256 + local_slot`) into bambuddy global tray IDs and uses it as a universal mapping source — working for all printer models and all print sources (slicer, queue, reprint) without relying on `tray_now` disambiguation. For printers that don't provide the MQTT mapping field (A1, A1 Mini, P1S, P2S), a color-matching fallback compares 3MF filament slot colors against AMS tray colors to resolve the correct slot-to-tray mapping. Gracefully returns no match when colors are ambiguous (duplicate tray colors) or unavailable.
- **AMS Slot Config: PFUS Preset IDs Cause Slicer to Reset Slots** — When assigning a spool with a user-local `PFUS*` preset ID (from BambuStudio's custom filament profiles), the slicer didn't recognize the ID and actively reset the AMS slot configuration. Now replaces `PFUS*` IDs with generic Bambu filament IDs (e.g., `GFL99` for PLA). When the slot already has a recognized cloud-synced preset for the same material (e.g., `P4d64437`), it is reused to preserve K-profile calibration associations. Applies to both the slot configure endpoint and the inventory spool assignment flow.
- **Fill Level Bar Missing for Brand New Spools** — Spools with `weight_used = 0` (brand new, never printed) showed no fill level bar on the printer card. The condition checked `weight_used > 0` instead of `weight_used != null`, excluding zero-usage spools. Now correctly shows 100% fill for new spools while still hiding the bar when weight data is unavailable (`null`).
- **npm audit: suppress moderate ajv ReDoS finding** — Added `audit-level=high` to `frontend/.npmrc` so `npm audit` exits cleanly. The ajv@6 ReDoS (GHSA-2g4f-4pwh-qvx6) is a transitive dependency of eslint@9 with no patched v6 release; ajv@8 override breaks eslint. The vulnerability requires crafted `$data` schema input — not an attack vector in a linting config.
- **npm audit: fix minimatch ReDoS finding** — Added an npm override for `minimatch@^10.2.1` in `package.json` to resolve the high-severity ReDoS (GHSA-3ppc-4f35-3m26) affecting minimatch@3.x/9.x pulled in transitively by eslint@9, typescript-eslint, and @vitest/coverage-v8. Eslint@9 pins minimatch@3.x with no patched release; eslint@10 upgrades to minimatch@10 but is not yet available. The override forces the patched version across the tree. Verified lint, build, and all tests pass.
- **Spool Form Allows Empty Brand & Subtype** ([#417](https://github.com/maziggy/bambuddy/issues/417)) — The spool add/edit modal did not require Brand or Subtype fields, allowing spools to be saved without them. When such a spool was assigned to an AMS slot, the `tray_sub_brands` sent to the printer was incomplete (e.g., just "PETG" instead of "PETG Basic"), causing BambuStudio to not recognize the filament profile. Brand and Subtype are now mandatory fields with validation errors shown on submit.
@@ -38,6 +40,7 @@ All notable changes to Bambuddy will be documented in this file.
### Improved
- **AMS Mapping Test Coverage** — Added 63 backend tests for scheduler AMS mapping (nozzle filtering, external spool extruder assignment, fallback behavior) and 43 frontend tests for `useFilamentMapping` hook (nozzle-aware matching, AMS-HT handling, external spool extruder logic).
- **Tray Now Disambiguation Test Coverage** — Added 28 MQTT message replay tests covering all `tray_now` disambiguation paths: single-nozzle passthrough (X1E/P2S), H2D dual-nozzle snow field, pending target, `ams_extruder_map` fallback, active extruder switching, and full multi-color print lifecycles.
- **Tray Info Idx Resolution Test Coverage** — Added 12 backend integration tests for PFUS→generic tray_info_idx resolution across both the slot configure and inventory assignment endpoints, plus 10 frontend unit tests for the fill level calculation logic.
## [0.2.0] - 2026-02-17
+46 -1
View File
@@ -640,9 +640,10 @@ async def assign_spool(
if spool.archived_at:
raise HTTPException(400, "Cannot assign an archived spool")
# 2. Get current AMS tray state for fingerprint
# 2. Get current AMS tray state for fingerprint + existing filament ID
fingerprint_color = None
fingerprint_type = None
current_tray_info_idx = ""
state = printer_manager.get_status(data.printer_id)
if state and state.raw_data:
if data.ams_id == 255:
@@ -653,6 +654,7 @@ async def assign_spool(
if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
fingerprint_color = vt.get("tray_color", "")
fingerprint_type = vt.get("tray_type", "")
current_tray_info_idx = vt.get("tray_info_idx", "")
break
else:
ams_data = state.raw_data.get("ams", {})
@@ -671,6 +673,7 @@ async def assign_spool(
if tray:
fingerprint_color = tray.get("tray_color", "")
fingerprint_type = tray.get("tray_type", "")
current_tray_info_idx = tray.get("tray_info_idx", "")
# 3. Upsert assignment (replace if same printer+ams+tray)
existing = await db.execute(
@@ -709,6 +712,48 @@ async def assign_spool(
tray_info_idx = spool.slicer_filament or ""
setting_id = ""
# Resolve tray_info_idx for the MQTT command.
# Priority:
# 1. Reuse the slot's existing tray_info_idx if it's a recognised
# preset (GF*/P*) for the same material — this preserves the
# slicer's K-profile association.
# 2. Replace PFUS* (user-local IDs unknown to other slicers) and
# empty IDs with a generic Bambu filament ID.
if (
current_tray_info_idx
and not current_tray_info_idx.startswith("PFUS")
and fingerprint_type
and fingerprint_type.upper() == tray_type.upper()
):
logger.info(
"Spool assign: reusing slot's existing tray_info_idx=%r (same material %r)",
current_tray_info_idx,
tray_type,
)
tray_info_idx = current_tray_info_idx
elif (not tray_info_idx or tray_info_idx.startswith("PFUS")) and tray_type:
_GENERIC_IDS = {
"PLA": "GFL99",
"PETG": "GFG99",
"ABS": "GFB99",
"ASA": "GFB98",
"PC": "GFC99",
"PA": "GFN99",
"NYLON": "GFN99",
"TPU": "GFU99",
"PVA": "GFS99",
"HIPS": "GFS98",
"PLA-CF": "GFL98",
"PETG-CF": "GFG98",
"PA-CF": "GFN98",
"PETG HF": "GFG96",
}
material = tray_type.upper().strip()
generic = _GENERIC_IDS.get(material) or _GENERIC_IDS.get(material.split("-")[0].split(" ")[0]) or ""
if generic:
logger.info("Spool assign: replacing %r with generic %r", tray_info_idx, generic)
tray_info_idx = generic
# Temperature: use spool overrides if set, else material defaults
temp_min, temp_max = MATERIAL_TEMPS.get(spool.material.upper(), (200, 240))
if spool.nozzle_temp_min is not None:
+98 -41
View File
@@ -1642,53 +1642,110 @@ async def configure_ams_slot(
if not client:
raise HTTPException(status_code=400, detail="Printer not connected")
# Detect RFID spool before sending commands
is_rfid_spool = False
state = printer_manager.get_status(printer_id)
if state and state.raw_data:
from backend.app.api.routes.inventory import _find_tray_in_ams_data
from backend.app.services.spool_tag_matcher import is_valid_tag
# Resolve tray_info_idx for the MQTT command.
# PFUS* IDs are user-local preset IDs that only the originating slicer
# recognises. When Bambuddy sends them, BambuStudio sees an unknown
# filament and actively resets the slot to empty.
# Priority:
# 1. If the slot already has a recognised preset (GF*/P*) for the same
# material, reuse it — preserves slicer's K-profile association.
# 2. Replace PFUS* / empty with a generic Bambu filament ID.
_GENERIC_FILAMENT_IDS = {
"PLA": "GFL99",
"PETG": "GFG99",
"ABS": "GFB99",
"ASA": "GFB98",
"PC": "GFC99",
"PA": "GFN99",
"NYLON": "GFN99",
"TPU": "GFU99",
"PVA": "GFS99",
"HIPS": "GFS98",
"PLA-CF": "GFL98",
"PETG-CF": "GFG98",
"PA-CF": "GFN98",
"PETG HF": "GFG96",
}
effective_tray_info_idx = tray_info_idx
ams_data = state.raw_data.get("ams", {})
ams_list = (
ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else []
)
current_tray = _find_tray_in_ams_data(ams_list, ams_id, tray_id)
if current_tray:
is_rfid_spool = is_valid_tag(
current_tray.get("tag_uid", ""),
current_tray.get("tray_uuid", ""),
if not tray_info_idx or tray_info_idx.startswith("PFUS"):
# Check if the slot already has a recognised preset for same material
current_tray_info_idx = ""
current_tray_type = ""
state = printer_manager.get_status(printer_id)
if state and state.raw_data:
from backend.app.api.routes.inventory import _find_tray_in_ams_data
if ams_id == 255:
vt_tray = state.raw_data.get("vt_tray") or []
ext_id = tray_id + 254
for vt in vt_tray:
if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
current_tray_info_idx = vt.get("tray_info_idx", "")
current_tray_type = vt.get("tray_type", "")
break
else:
ams_data = state.raw_data.get("ams", {})
ams_list = (
ams_data.get("ams", [])
if isinstance(ams_data, dict)
else ams_data
if isinstance(ams_data, list)
else []
)
cur_tray = _find_tray_in_ams_data(ams_list, ams_id, tray_id)
if cur_tray:
current_tray_info_idx = cur_tray.get("tray_info_idx", "")
current_tray_type = cur_tray.get("tray_type", "")
if (
current_tray_info_idx
and not current_tray_info_idx.startswith("PFUS")
and current_tray_type
and current_tray_type.upper() == tray_type.upper()
):
logger.info(
"[configure_ams_slot] Reusing slot's existing tray_info_idx=%r (same material %r)",
current_tray_info_idx,
tray_type,
)
effective_tray_info_idx = current_tray_info_idx
elif tray_type:
material = tray_type.upper().strip()
generic = (
_GENERIC_FILAMENT_IDS.get(material)
or _GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
or ""
)
if generic:
if tray_info_idx:
logger.info("[configure_ams_slot] Replacing user-local %r with generic %r", tray_info_idx, generic)
else:
logger.info("[configure_ams_slot] Deriving tray_info_idx=%r from tray_type=%r", generic, tray_type)
effective_tray_info_idx = generic
# Send filament setting + K-profile commands
filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else tray_info_idx
filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else effective_tray_info_idx
if is_rfid_spool:
# RFID spool: skip ams_set_filament_setting to preserve RFID state (eye icon).
# The firmware already has filament config from the RFID tag.
logger.info("[configure_ams_slot] RFID spool detected — skipping ams_set_filament_setting")
else:
# Non-RFID spool: send filament setting (type, color, temp)
# When a K-profile is selected, use the K-profile's filament_id as
# tray_info_idx so BambuStudio queries the right PA history table.
# But always use the PRESET's setting_id (not the K-profile's) —
# BambuStudio uses setting_id to identify the filament preset and
# overriding it with the K-profile's setting_id confuses the slicer.
effective_tray_info_idx = filament_id_for_kprofile if cali_idx >= 0 else tray_info_idx
success = client.ams_set_filament_setting(
ams_id=ams_id,
tray_id=tray_id,
tray_info_idx=effective_tray_info_idx,
tray_type=tray_type,
tray_sub_brands=tray_sub_brands,
tray_color=tray_color,
nozzle_temp_min=nozzle_temp_min,
nozzle_temp_max=nozzle_temp_max,
setting_id=setting_id,
)
# Always send ams_set_filament_setting — the user explicitly clicked
# "Configure Slot", so honor that. Previous versions skipped this for
# RFID-tagged slots to preserve the slicer eye icon, but printers cache
# stale tag_uid/tray_uuid after a BL spool is removed, causing the check
# to false-positive on non-RFID slots and silently drop the command.
success = client.ams_set_filament_setting(
ams_id=ams_id,
tray_id=tray_id,
tray_info_idx=effective_tray_info_idx,
tray_type=tray_type,
tray_sub_brands=tray_sub_brands,
tray_color=tray_color,
nozzle_temp_min=nozzle_temp_min,
nozzle_temp_max=nozzle_temp_max,
setting_id=setting_id,
)
if not success:
raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
if not success:
raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
# Method 1: Select existing calibration profile by cali_idx
# Do NOT include setting_id — BambuStudio never sends it in extrusion_cali_sel,
@@ -0,0 +1,225 @@
"""Integration tests for inventory spool assignment — tray_info_idx resolution.
Tests that PFUS* user-local preset IDs are replaced with generic Bambu IDs,
and that existing recognised presets on slots are reused when the material matches.
"""
from unittest.mock import MagicMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.spool import Spool
@pytest.fixture
async def spool_factory(db_session: AsyncSession):
"""Factory to create test spools."""
_counter = [0]
async def _create_spool(**kwargs):
_counter[0] += 1
defaults = {
"material": "PLA",
"subtype": "Basic",
"brand": "Devil Design",
"color_name": "Red",
"rgba": "FF0000FF",
"label_weight": 1000,
"weight_used": 0,
"slicer_filament": "PFUS9ac902733670a9",
}
defaults.update(kwargs)
spool = Spool(**defaults)
db_session.add(spool)
await db_session.commit()
await db_session.refresh(spool)
return spool
return _create_spool
def _make_mock_status(ams_data=None, vt_tray=None, nozzles=None, ams_extruder_map=None):
"""Build a mock printer status with optional AMS/nozzle data."""
status = MagicMock()
raw = {}
if ams_data is not None:
raw["ams"] = {"ams": ams_data}
if vt_tray is not None:
raw["vt_tray"] = vt_tray
status.raw_data = raw
status.nozzles = nozzles or [MagicMock(nozzle_diameter="0.4")]
status.ams_extruder_map = ams_extruder_map
return status
class TestAssignSpoolTrayInfoIdx:
"""Tests for tray_info_idx resolution during spool assignment."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_pfus_replaced_with_generic(self, async_client: AsyncClient, printer_factory, spool_factory):
"""PFUS* user-local IDs are replaced with generic Bambu IDs."""
printer = await printer_factory(name="H2D")
spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
mock_client = MagicMock()
mock_client.ams_set_filament_setting.return_value = True
mock_client.extrusion_cali_sel.return_value = True
status = _make_mock_status(ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "", "tray_type": ""}]}])
with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
mock_pm.get_status.return_value = status
response = await async_client.post(
"/api/v1/inventory/assignments",
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
)
assert response.status_code == 200
call_kwargs = mock_client.ams_set_filament_setting.call_args
assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reuses_existing_recognised_preset(self, async_client: AsyncClient, printer_factory, spool_factory):
"""When slot already has a recognised preset for same material, reuse it."""
printer = await printer_factory(name="H2D")
spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PLA")
mock_client = MagicMock()
mock_client.ams_set_filament_setting.return_value = True
mock_client.extrusion_cali_sel.return_value = True
# Slot already configured by slicer with cloud-synced preset
status = _make_mock_status(
ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
)
with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
mock_pm.get_status.return_value = status
response = await async_client.post(
"/api/v1/inventory/assignments",
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
)
assert response.status_code == 200
call_kwargs = mock_client.ams_set_filament_setting.call_args
# Should reuse the slicer's cloud-synced ID
assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_different_material_uses_generic(self, async_client: AsyncClient, printer_factory, spool_factory):
"""When slot has a preset for a DIFFERENT material, use generic ID."""
printer = await printer_factory(name="H2D")
spool = await spool_factory(slicer_filament="PFUS9ac902733670a9", material="PETG")
mock_client = MagicMock()
mock_client.ams_set_filament_setting.return_value = True
mock_client.extrusion_cali_sel.return_value = True
# Slot currently has PLA but spool is PETG
status = _make_mock_status(
ams_data=[{"id": 2, "tray": [{"id": 3, "tray_info_idx": "P4d64437", "tray_type": "PLA"}]}]
)
with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
mock_pm.get_status.return_value = status
response = await async_client.post(
"/api/v1/inventory/assignments",
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 2, "tray_id": 3},
)
assert response.status_code == 200
call_kwargs = mock_client.ams_set_filament_setting.call_args
assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_gf_slicer_filament_kept(self, async_client: AsyncClient, printer_factory, spool_factory):
"""Standard GF* IDs from spool.slicer_filament are used directly."""
printer = await printer_factory(name="X1C")
spool = await spool_factory(slicer_filament="GFL05", material="PLA")
mock_client = MagicMock()
mock_client.ams_set_filament_setting.return_value = True
mock_client.extrusion_cali_sel.return_value = True
status = _make_mock_status(ams_data=[])
with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
mock_pm.get_status.return_value = status
response = await async_client.post(
"/api/v1/inventory/assignments",
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
)
assert response.status_code == 200
call_kwargs = mock_client.ams_set_filament_setting.call_args
assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_empty_slicer_filament_uses_generic(self, async_client: AsyncClient, printer_factory, spool_factory):
"""Spool with no slicer_filament gets a generic ID from material type."""
printer = await printer_factory(name="X1C")
spool = await spool_factory(slicer_filament=None, material="ABS")
mock_client = MagicMock()
mock_client.ams_set_filament_setting.return_value = True
mock_client.extrusion_cali_sel.return_value = True
status = _make_mock_status(ams_data=[])
with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
mock_pm.get_status.return_value = status
response = await async_client.post(
"/api/v1/inventory/assignments",
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
)
assert response.status_code == 200
call_kwargs = mock_client.ams_set_filament_setting.call_args
assert call_kwargs.kwargs["tray_info_idx"] == "GFB99"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_existing_pfus_on_slot_not_reused(self, async_client: AsyncClient, printer_factory, spool_factory):
"""A PFUS* ID already on the slot should NOT be reused (it's also user-local)."""
printer = await printer_factory(name="H2D")
spool = await spool_factory(slicer_filament="PFUS1111111111", material="PLA")
mock_client = MagicMock()
mock_client.ams_set_filament_setting.return_value = True
mock_client.extrusion_cali_sel.return_value = True
# Slot has a PFUS* ID from some previous config
status = _make_mock_status(
ams_data=[{"id": 0, "tray": [{"id": 0, "tray_info_idx": "PFUS2222222222", "tray_type": "PLA"}]}]
)
with patch("backend.app.services.printer_manager.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
mock_pm.get_status.return_value = status
response = await async_client.post(
"/api/v1/inventory/assignments",
json={"spool_id": spool.id, "printer_id": printer.id, "ams_id": 0, "tray_id": 0},
)
assert response.status_code == 200
call_kwargs = mock_client.ams_set_filament_setting.call_args
# Should NOT reuse the PFUS on the slot — use generic instead
assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
@@ -580,6 +580,230 @@ class TestAMSRefreshAPI:
assert "unload" in response.json()["detail"].lower()
class TestConfigureAMSSlotAPI:
"""Integration tests for AMS slot configure endpoint — tray_info_idx resolution."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_configure_not_connected(self, async_client: AsyncClient, printer_factory):
"""Verify error when printer is not connected."""
printer = await printer_factory(name="Disconnected")
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
mock_pm.get_client.return_value = None
response = await async_client.post(
f"/api/v1/printers/{printer.id}/slots/0/0/configure",
params={
"tray_info_idx": "GFL99",
"tray_type": "PLA",
"tray_sub_brands": "PLA Basic",
"tray_color": "FF0000FF",
"nozzle_temp_min": 190,
"nozzle_temp_max": 230,
},
)
assert response.status_code == 400
assert "not connected" in response.json()["detail"].lower()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_configure_with_gf_id_keeps_it(self, async_client: AsyncClient, printer_factory):
"""Standard Bambu GF* filament IDs are sent as-is."""
printer = await printer_factory(name="H2D")
mock_client = MagicMock()
mock_client.ams_set_filament_setting.return_value = True
mock_client.extrusion_cali_sel.return_value = True
mock_client.request_status_update.return_value = True
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
mock_pm.get_status.return_value = None # No existing state
response = await async_client.post(
f"/api/v1/printers/{printer.id}/slots/2/3/configure",
params={
"tray_info_idx": "GFL05",
"tray_type": "PLA",
"tray_sub_brands": "PLA Basic",
"tray_color": "FFFFFFFF",
"nozzle_temp_min": 190,
"nozzle_temp_max": 230,
},
)
assert response.status_code == 200
call_kwargs = mock_client.ams_set_filament_setting.call_args
assert call_kwargs.kwargs["tray_info_idx"] == "GFL05"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_configure_pfus_replaced_with_generic(self, async_client: AsyncClient, printer_factory):
"""PFUS* user-local IDs are replaced with generic Bambu IDs."""
printer = await printer_factory(name="H2D")
mock_client = MagicMock()
mock_client.ams_set_filament_setting.return_value = True
mock_client.extrusion_cali_sel.return_value = True
mock_client.request_status_update.return_value = True
mock_status = MagicMock()
mock_status.raw_data = {"ams": {"ams": []}} # No existing tray data
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
mock_pm.get_status.return_value = mock_status
response = await async_client.post(
f"/api/v1/printers/{printer.id}/slots/2/3/configure",
params={
"tray_info_idx": "PFUS9ac902733670a9",
"tray_type": "PLA",
"tray_sub_brands": "Devil Design PLA",
"tray_color": "FF0000FF",
"nozzle_temp_min": 190,
"nozzle_temp_max": 230,
},
)
assert response.status_code == 200
call_kwargs = mock_client.ams_set_filament_setting.call_args
assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_configure_pfus_reuses_existing_slot_id(self, async_client: AsyncClient, printer_factory):
"""When slot already has a recognised preset for same material, reuse it."""
printer = await printer_factory(name="H2D")
mock_client = MagicMock()
mock_client.ams_set_filament_setting.return_value = True
mock_client.extrusion_cali_sel.return_value = True
mock_client.request_status_update.return_value = True
# Simulate slot already configured by slicer with cloud-synced preset
mock_status = MagicMock()
mock_status.raw_data = {
"ams": {
"ams": [
{
"id": 2,
"tray": [
{
"id": 3,
"tray_info_idx": "P4d64437",
"tray_type": "PLA",
"tray_color": "FF0000FF",
}
],
}
]
}
}
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
mock_pm.get_status.return_value = mock_status
response = await async_client.post(
f"/api/v1/printers/{printer.id}/slots/2/3/configure",
params={
"tray_info_idx": "PFUS9ac902733670a9",
"tray_type": "PLA",
"tray_sub_brands": "Devil Design PLA",
"tray_color": "FF0000FF",
"nozzle_temp_min": 190,
"nozzle_temp_max": 230,
},
)
assert response.status_code == 200
call_kwargs = mock_client.ams_set_filament_setting.call_args
# Should reuse the slicer's P4d64437, not replace with GFL99
assert call_kwargs.kwargs["tray_info_idx"] == "P4d64437"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_configure_pfus_different_material_uses_generic(self, async_client: AsyncClient, printer_factory):
"""When slot has a recognised preset but for DIFFERENT material, use generic."""
printer = await printer_factory(name="H2D")
mock_client = MagicMock()
mock_client.ams_set_filament_setting.return_value = True
mock_client.extrusion_cali_sel.return_value = True
mock_client.request_status_update.return_value = True
# Slot currently has PETG but user is configuring PLA
mock_status = MagicMock()
mock_status.raw_data = {
"ams": {
"ams": [
{
"id": 2,
"tray": [{"id": 3, "tray_info_idx": "GFG99", "tray_type": "PETG", "tray_color": "FFFFFFFF"}],
}
]
}
}
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
mock_pm.get_status.return_value = mock_status
response = await async_client.post(
f"/api/v1/printers/{printer.id}/slots/2/3/configure",
params={
"tray_info_idx": "PFUS9ac902733670a9",
"tray_type": "PLA",
"tray_sub_brands": "Devil Design PLA",
"tray_color": "FF0000FF",
"nozzle_temp_min": 190,
"nozzle_temp_max": 230,
},
)
assert response.status_code == 200
call_kwargs = mock_client.ams_set_filament_setting.call_args
# Different material → should NOT reuse PETG ID, use generic PLA
assert call_kwargs.kwargs["tray_info_idx"] == "GFL99"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_configure_empty_id_uses_generic(self, async_client: AsyncClient, printer_factory):
"""Empty tray_info_idx (local preset) is replaced with generic."""
printer = await printer_factory(name="H2D")
mock_client = MagicMock()
mock_client.ams_set_filament_setting.return_value = True
mock_client.extrusion_cali_sel.return_value = True
mock_client.request_status_update.return_value = True
mock_status = MagicMock()
mock_status.raw_data = {"ams": {"ams": []}}
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
mock_pm.get_client.return_value = mock_client
mock_pm.get_status.return_value = mock_status
response = await async_client.post(
f"/api/v1/printers/{printer.id}/slots/2/3/configure",
params={
"tray_info_idx": "",
"tray_type": "PETG",
"tray_sub_brands": "PETG Basic",
"tray_color": "FFFFFFFF",
"nozzle_temp_min": 220,
"nozzle_temp_max": 260,
},
)
assert response.status_code == 200
call_kwargs = mock_client.ams_set_filament_setting.call_args
assert call_kwargs.kwargs["tray_info_idx"] == "GFG99"
class TestSkipObjectsAPI:
"""Integration tests for skip objects endpoints."""
+20 -7
View File
@@ -10,7 +10,7 @@
# ./docker-publish.sh 0.1.9b --ghcr-only # Only push to GHCR
# ./docker-publish.sh 0.1.9b --dockerhub-only # Only push to Docker Hub
#
# Note: All versions are also tagged as 'latest'
# Note: Stable versions are also tagged as 'latest'. Beta versions (ending in 'b') are not.
#
# Prerequisites:
# 1. Log in to ghcr.io:
@@ -143,13 +143,22 @@ if ! docker buildx inspect --bootstrap | grep -q "linux/arm64"; then
docker run --privileged --rm tonistiigi/binfmt --install all
fi
# Only tag as 'latest' for stable releases (not beta versions ending in 'b')
TAG_LATEST=true
if [[ "$VERSION" =~ b$ ]]; then
TAG_LATEST=false
echo -e "${YELLOW}Beta version detected — skipping 'latest' tag${NC}"
fi
# Build tags for all target registries
TAGS=""
if [ "$PUSH_GHCR" = true ]; then
TAGS="$TAGS -t ${GHCR_IMAGE}:${VERSION} -t ${GHCR_IMAGE}:latest"
TAGS="$TAGS -t ${GHCR_IMAGE}:${VERSION}"
[ "$TAG_LATEST" = true ] && TAGS="$TAGS -t ${GHCR_IMAGE}:latest"
fi
if [ "$PUSH_DOCKERHUB" = true ]; then
TAGS="$TAGS -t ${DOCKERHUB_IMAGE}:${VERSION} -t ${DOCKERHUB_IMAGE}:latest"
TAGS="$TAGS -t ${DOCKERHUB_IMAGE}:${VERSION}"
[ "$TAG_LATEST" = true ] && TAGS="$TAGS -t ${DOCKERHUB_IMAGE}:latest"
fi
echo -e "${BLUE}[3/4] Building and pushing...${NC}"
@@ -209,15 +218,19 @@ if [ "$PARALLEL" = true ]; then
if [ "$PUSH_GHCR" = true ]; then
echo -e "${BLUE} Creating GHCR manifest...${NC}"
GHCR_MANIFEST_TAGS="-t ${GHCR_IMAGE}:${VERSION}"
[ "$TAG_LATEST" = true ] && GHCR_MANIFEST_TAGS="$GHCR_MANIFEST_TAGS -t ${GHCR_IMAGE}:latest"
docker buildx imagetools create \
-t "${GHCR_IMAGE}:${VERSION}" -t "${GHCR_IMAGE}:latest" \
$GHCR_MANIFEST_TAGS \
"${GHCR_IMAGE}:${VERSION}-amd64" \
"${GHCR_IMAGE}:${VERSION}-arm64"
fi
if [ "$PUSH_DOCKERHUB" = true ]; then
echo -e "${BLUE} Creating Docker Hub manifest...${NC}"
DH_MANIFEST_TAGS="-t ${DOCKERHUB_IMAGE}:${VERSION}"
[ "$TAG_LATEST" = true ] && DH_MANIFEST_TAGS="$DH_MANIFEST_TAGS -t ${DOCKERHUB_IMAGE}:latest"
docker buildx imagetools create \
-t "${DOCKERHUB_IMAGE}:${VERSION}" -t "${DOCKERHUB_IMAGE}:latest" \
$DH_MANIFEST_TAGS \
"${DOCKERHUB_IMAGE}:${VERSION}-amd64" \
"${DOCKERHUB_IMAGE}:${VERSION}-arm64"
fi
@@ -249,12 +262,12 @@ echo -e "${GREEN}================================================${NC}"
if [ "$PUSH_GHCR" = true ]; then
echo " GHCR:"
echo " - ${GHCR_IMAGE}:${VERSION}"
echo " - ${GHCR_IMAGE}:latest"
[ "$TAG_LATEST" = true ] && echo " - ${GHCR_IMAGE}:latest"
fi
if [ "$PUSH_DOCKERHUB" = true ]; then
echo " Docker Hub:"
echo " - ${DOCKERHUB_IMAGE}:${VERSION}"
echo " - ${DOCKERHUB_IMAGE}:latest"
[ "$TAG_LATEST" = true ] && echo " - ${DOCKERHUB_IMAGE}:latest"
fi
echo ""
echo -e "${BLUE}Supported platforms:${NC}"
@@ -0,0 +1,81 @@
/**
* Tests for inventory fill level calculation logic.
*
* The fill level is calculated inline in PrintersPage.tsx as:
* if (sp && sp.label_weight > 0 && sp.weight_used != null)
* → Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100)
* else → null
*
* These tests validate the calculation logic directly, particularly the
* fix for weight_used == null (brand new spools) and weight_used == 0.
*/
import { describe, it, expect } from 'vitest';
/**
* Mirrors the inline inventoryFill calculation from PrintersPage.tsx.
* Extracted here for testability.
*/
function computeInventoryFill(spool: { label_weight: number; weight_used: number | null } | null): number | null {
const sp = spool;
if (sp && sp.label_weight > 0 && sp.weight_used != null) {
return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
}
return null;
}
describe('inventoryFill calculation', () => {
it('returns 100% for brand new spool with weight_used = 0', () => {
expect(computeInventoryFill({ label_weight: 1000, weight_used: 0 })).toBe(100);
});
it('returns null for brand new spool with weight_used = null', () => {
// weight_used null means "never tracked" — we can't compute fill
expect(computeInventoryFill({ label_weight: 1000, weight_used: null })).toBeNull();
});
it('returns correct percentage for partially used spool', () => {
expect(computeInventoryFill({ label_weight: 1000, weight_used: 250 })).toBe(75);
});
it('returns 0% for fully used spool', () => {
expect(computeInventoryFill({ label_weight: 1000, weight_used: 1000 })).toBe(0);
});
it('returns 0% when weight_used exceeds label_weight', () => {
// Math.max(0, ...) prevents negative fill
expect(computeInventoryFill({ label_weight: 1000, weight_used: 1200 })).toBe(0);
});
it('returns null when no spool data', () => {
expect(computeInventoryFill(null)).toBeNull();
});
it('returns null when label_weight is 0', () => {
expect(computeInventoryFill({ label_weight: 0, weight_used: 0 })).toBeNull();
});
it('rounds to nearest integer', () => {
// 1000 - 333 = 667, 667/1000 = 66.7 → 67
expect(computeInventoryFill({ label_weight: 1000, weight_used: 333 })).toBe(67);
});
});
describe('inventoryFill: old bug — weight_used > 0 vs weight_used != null', () => {
/**
* The old condition was: sp.weight_used > 0
* This caused brand-new spools (weight_used=0) to show no fill bar.
* The fix changed it to: sp.weight_used != null
*/
it('weight_used = 0 now shows fill (was broken with > 0 check)', () => {
// Old: 0 > 0 = false → null (no fill bar)
// New: 0 != null = true → 100% fill
const result = computeInventoryFill({ label_weight: 1000, weight_used: 0 });
expect(result).toBe(100);
expect(result).not.toBeNull();
});
it('weight_used = 0.1 shows fill (small usage)', () => {
const result = computeInventoryFill({ label_weight: 1000, weight_used: 0.1 });
expect(result).toBe(100); // rounds to 100 since 0.1g from 1000g is negligible
});
});
@@ -322,8 +322,26 @@ export function ConfigureAmsSlotModal({
let settingId: string;
if (isLocal) {
// Local presets have no Bambu Cloud mapping
trayInfoIdx = '';
// Local presets have no Bambu Cloud setting_id, but need a valid
// tray_info_idx for the printer to recognize the filament type.
// Map the material type to the closest generic Bambu filament ID.
const material = (localPreset?.filament_type || parsed.material || '').toUpperCase();
const GENERIC_IDS: Record<string, string> = {
'PLA': 'GFL99', 'PLA-CF': 'GFL98', 'PLA SILK': 'GFL96', 'PLA HIGH SPEED': 'GFL95',
'PETG': 'GFG99', 'PETG HF': 'GFG96', 'PETG-CF': 'GFG98', 'PCTG': 'GFG97',
'ABS': 'GFB99', 'ASA': 'GFB98',
'PC': 'GFC99',
'PA': 'GFN99', 'PA-CF': 'GFN98', 'NYLON': 'GFN99',
'TPU': 'GFU99',
'PVA': 'GFS99', 'HIPS': 'GFS98',
'PE': 'GFP99', 'PP': 'GFP97',
};
// Try exact match first, then base material (strip suffixes like "-CF", "+", " HF")
trayInfoIdx = GENERIC_IDS[material]
|| GENERIC_IDS[material.replace(/[-\s]?CF$/, '')]
|| GENERIC_IDS[material.replace(/\+$/, '')]
|| GENERIC_IDS[material.split(/[-\s]/)[0]]
|| '';
settingId = '';
} else if (isBuiltin) {
// Built-in presets use the filament_id directly as tray_info_idx
+3 -3
View File
@@ -2770,7 +2770,7 @@ function PrinterCard({
const inventoryAssignment = onGetAssignment?.(printer.id, ams.id, slotIdx);
const inventoryFill = (() => {
const sp = inventoryAssignment?.spool;
if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
if (sp && sp.label_weight > 0 && sp.weight_used != null) {
return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
}
return null;
@@ -2995,7 +2995,7 @@ function PrinterCard({
const htInventoryAssignment = onGetAssignment?.(printer.id, ams.id, htTraySlotId);
const htInventoryFill = (() => {
const sp = htInventoryAssignment?.spool;
if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
if (sp && sp.label_weight > 0 && sp.weight_used != null) {
return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
}
return null;
@@ -3253,7 +3253,7 @@ function PrinterCard({
const extInventoryAssignment = onGetAssignment?.(printer.id, 255, slotTrayId);
const extInventoryFill = (() => {
const sp = extInventoryAssignment?.spool;
if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
if (sp && sp.label_weight > 0 && sp.weight_used != null) {
return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
}
return null;
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -23,7 +23,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-ADBQB8en.js"></script>
<script type="module" crossorigin src="/assets/index-jjif866q.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DPf6CLKV.css">
</head>
<body>