mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
Use the vendor's empty spool weight as tare in Spoolman mode (#3195)
Spoolman resolves a spool's tare from the spool, then the filament, then the vendor's empty_spool_weight. Bambuddy skipped the vendor and fell back to 250 g, so weighing a spool whose tare was set only on its vendor gave the wrong remaining weight. The inventory weigh action, the SpoolBuddy scale and the displayed core weight now share one lookup, and "keep old weight" on a filament change stamps a vendor-inherited tare.
This commit is contained in:
@@ -52,6 +52,7 @@ 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
|
||||
- **In Spoolman mode, weighing a spool now uses the vendor's empty spool weight (#3195, reported by @worried-networking)** — Spoolman finds a spool's empty weight in three places: the spool's own **Spool Weight**, then the filament's, then the vendor's **Empty Spool Weight**. Its own `/measure` endpoint uses that order. Bambuddy stopped after the filament and used 250 g instead of the vendor's value. So a spool whose tare was set only on its vendor weighed against the wrong core: a Sunlu spool (211.7 g) at 1177 g on the scale was saved as 927 g remaining instead of 965.3 g. The same error was in three places, and each is fixed: the weigh action in the inventory, the SpoolBuddy scale, and the **Empty Spool Weight** shown in the spool form and used for gross weight in the list. All three now share one lookup, so they can't drift apart again. The SpoolBuddy "no spool weight set, using 250 g" warning now appears only when the spool, the filament and the vendor all have no weight. The spool form still sends the empty weight to Spoolman only when you change it, so opening and saving a spool does not copy the vendor's value onto it. Changing a filament's spool weight with **Keep old weight for existing spools** now keeps a vendor-inherited tare too: when the filament had no weight of its own, the spools that inherited the vendor's value get that value, not the new filament weight.
|
||||
- **A P1P, P1S or X1E added by discovery is saved as the right printer** — Bambu printers announce themselves with an internal code, and the table that turns it into a model name had the P-series codes shifted: a discovered P1S was saved as a P1P, a P1P as a P1S, and an X1E as a P2S. The same mix-up sat in the backend: a file sliced for a P1P or P1S could say it was sliced for an X1C or X1, the X1E's code checked the P2S firmware line, and the P2S's code was missing. All of them now agree with what the printers and 3MF files actually report. Printers saved under a raw code by an older version also get the right ethernet, rod-type and storage handling. A printer that was already saved with the wrong model keeps it: pick the right one under **Model** in **Edit** from the printer card's menu.
|
||||
- **Generate Thumbnails works for users who can edit only their own files, and external folder scans no longer wait on PDF thumbnails** — The toolbar's **Generate Thumbnails** button and the file menu's **Generate Thumbnail** entry were offered to users who may edit only their own library files, but the server refused them. For those users both now cover the files they uploaded themselves; users who may edit all files still get everyone's. Scanning an external folder rendered each new PDF's thumbnail before the scan finished, which on a Raspberry Pi with a share full of PDFs kept the scan waiting for a long time. PDF thumbnails are now made in the background after the scan, like STL thumbnails already were.
|
||||
- **The Low Filament notification now fires (#2913, reported and contributed by @ojimpo in #2940)** — The event had a toggle on every notification provider, but nothing in Bambuddy ever sent it, so switching it on did nothing. It now alerts when a spool assigned to an AMS, AMS-HT or external slot drops below its low-stock threshold. That is the same global percentage (default 20 %) and per-spool override that drive the Low Stock count on the Inventory page, so the alert and the count agree. Remaining filament comes from the spool's weight, not the AMS remain percentage, which can be far off. It works with the built-in inventory and with Spoolman; with Spoolman only the global threshold applies. Each spool alerts once per slot and can alert again after it goes back above the threshold, for example when a fresh one is loaded. Archived spools and slots with no assigned spool never alert. The check runs every 30 seconds and does nothing unless a provider has the event switched on. `{color}` is now filled in for custom templates. If you switched Low Filament on in the past, expect one alert for each assigned spool that is already low after updating, and again after each restart while it stays low.
|
||||
|
||||
@@ -144,6 +144,38 @@ def _safe_optional_float(value: object) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
# Used only when Spoolman has no empty-spool weight at any level.
|
||||
SPOOLMAN_FALLBACK_TARE = 250.0
|
||||
|
||||
|
||||
def spoolman_tare(spool: dict) -> tuple[float, str]:
|
||||
"""The empty-spool weight of a Spoolman spool, and where it came from.
|
||||
|
||||
Spoolman resolves the tare as the spool's own ``spool_weight``, then the
|
||||
filament's ``spool_weight``, then the vendor's ``empty_spool_weight``, and
|
||||
its own ``/measure`` endpoint follows that order. Skipping the vendor
|
||||
level made a spool whose tare lives only on its vendor weigh against
|
||||
250 g instead (#3195). Every Spoolman-mode tare in Bambuddy goes through
|
||||
here so the weigh endpoints and the displayed core weight cannot drift
|
||||
apart again.
|
||||
|
||||
Returns ``(grams, source)`` with source one of ``"spool"``,
|
||||
``"filament"``, ``"vendor"`` or ``"fallback"``. 0 is a real tare, not a
|
||||
missing one; a value that is not a finite number counts as missing.
|
||||
"""
|
||||
filament = spool.get("filament") or {}
|
||||
vendor = filament.get("vendor") or {}
|
||||
for source, raw in (
|
||||
("spool", spool.get("spool_weight")),
|
||||
("filament", filament.get("spool_weight")),
|
||||
("vendor", vendor.get("empty_spool_weight")),
|
||||
):
|
||||
value = _safe_optional_float(raw)
|
||||
if value is not None:
|
||||
return value, source
|
||||
return SPOOLMAN_FALLBACK_TARE, "fallback"
|
||||
|
||||
|
||||
def _extract_extra_str(extra: dict, key: str) -> str:
|
||||
"""Extract a JSON-encoded string from a Spoolman extra dict.
|
||||
|
||||
@@ -380,11 +412,9 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
|
||||
"effect_type": None,
|
||||
"brand": vendor.get("name") or None,
|
||||
"label_weight": label_weight,
|
||||
"core_weight": _safe_int(
|
||||
spool.get("spool_weight") if spool.get("spool_weight") is not None else filament.get("spool_weight"), 250
|
||||
),
|
||||
"core_weight": _safe_int(spoolman_tare(spool)[0], 250),
|
||||
# True when the spool has no spool_weight of its own and core_weight is
|
||||
# the filament type's (or the 250 g fallback). The spool form needs it
|
||||
# the filament type's, the vendor's or the 250 g fallback. The spool form needs it
|
||||
# to copy a spool without dropping an own tare or stamping an
|
||||
# inherited one (#2908).
|
||||
"core_weight_is_inherited": spool.get("spool_weight") is None,
|
||||
|
||||
@@ -891,7 +891,7 @@ async def update_spool_weight(
|
||||
update while the Spoolman row the user is actually looking at stayed
|
||||
unchanged (#1530). Mirrors the routing already used by ``nfc/tag-scanned``.
|
||||
"""
|
||||
from backend.app.api.routes._spoolman_helpers import _safe_float
|
||||
from backend.app.api.routes._spoolman_helpers import _safe_float, spoolman_tare
|
||||
from backend.app.models.spool import Spool
|
||||
|
||||
sm_client = await _get_spoolman_client_or_none(db)
|
||||
@@ -921,18 +921,17 @@ async def update_spool_weight(
|
||||
sm_spool = await sm_client.get_spool(req.spool_id)
|
||||
|
||||
filament = sm_spool.get("filament") or {}
|
||||
spool_tare = sm_spool.get("spool_weight")
|
||||
raw_tare = spool_tare if spool_tare is not None else filament.get("spool_weight")
|
||||
core_weight, tare_source = spoolman_tare(sm_spool)
|
||||
spool_weight_warning: str | None = None
|
||||
if raw_tare is None:
|
||||
if tare_source == "fallback":
|
||||
logger.warning(
|
||||
"Spoolman spool %d has no spool_weight set; using 250g fallback for tare",
|
||||
"Spoolman spool %d has no spool_weight or vendor empty_spool_weight set; using 250g fallback for tare",
|
||||
req.spool_id,
|
||||
)
|
||||
spool_weight_warning = (
|
||||
"spool_weight_not_set: Spoolman filament has no spool_weight configured; weight estimate uses 250g fallback"
|
||||
"spool_weight_not_set: Spoolman spool, filament and vendor have no empty-spool weight configured; "
|
||||
"weight estimate uses 250g fallback"
|
||||
)
|
||||
core_weight = _safe_float(raw_tare, 250.0)
|
||||
label_weight = _safe_float(filament.get("weight"), 1000.0)
|
||||
remaining_weight = max(0.0, req.weight_grams - core_weight)
|
||||
|
||||
|
||||
@@ -26,10 +26,10 @@ from backend.app.api.routes._spoolman_helpers import (
|
||||
NormalizedFilament,
|
||||
NormalizedVendorRef,
|
||||
_map_spoolman_spool,
|
||||
_safe_float,
|
||||
_safe_int,
|
||||
_safe_optional_float,
|
||||
assert_safe_spoolman_url,
|
||||
spoolman_tare,
|
||||
)
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
@@ -1188,18 +1188,15 @@ async def sync_spool_weight(
|
||||
) -> dict:
|
||||
"""Update a spool's remaining weight from a measured gross weight.
|
||||
|
||||
Computes remaining = gross_weight - tare, where tare = spool.spool_weight
|
||||
if set, else filament.spool_weight; falls back to 250 g when both unset.
|
||||
Computes remaining = gross_weight - tare, with the tare resolved the way
|
||||
Spoolman does it (spool, filament, vendor, then 250 g; see spoolman_tare).
|
||||
"""
|
||||
client = await _get_client(db)
|
||||
|
||||
async with _translate_spoolman_errors():
|
||||
current = await client.get_spool(spool_id)
|
||||
|
||||
cur_filament = current.get("filament") or {}
|
||||
spool_tare = current.get("spool_weight")
|
||||
raw_tare = spool_tare if spool_tare is not None else cur_filament.get("spool_weight")
|
||||
core_weight = _safe_float(raw_tare, 250.0)
|
||||
core_weight, _source = spoolman_tare(current)
|
||||
remaining = max(0.0, data.weight_grams - core_weight)
|
||||
|
||||
async with _translate_spoolman_errors():
|
||||
@@ -2061,7 +2058,8 @@ async def patch_spoolman_filament(
|
||||
"""Update a Spoolman filament's name and/or spool_weight.
|
||||
|
||||
When spool_weight changes, Option A (keep_existing_spools=True) stamps the old
|
||||
weight onto spools currently inheriting it (spool.spool_weight is None) so their
|
||||
weight (the filament's, or its vendor's empty_spool_weight when the filament had
|
||||
none) onto spools currently inheriting it (spool.spool_weight is None) so their
|
||||
tare calculations are unaffected by the filament change.
|
||||
Option B (keep_existing_spools=False, the default): when spool_weight is a
|
||||
concrete value, stamps it onto every affected spool explicitly; when spool_weight
|
||||
@@ -2089,7 +2087,13 @@ async def patch_spoolman_filament(
|
||||
|
||||
if affected_spools:
|
||||
if body.keep_existing_spools:
|
||||
# What the inheriting spools weigh against today: the filament's
|
||||
# own value, or when it has none, its vendor's (#3195). With
|
||||
# neither they sit on the 250 g fallback, which is Bambuddy's
|
||||
# alone, so there is nothing real to keep.
|
||||
old_weight = _safe_optional_float(current.get("spool_weight"))
|
||||
if old_weight is None:
|
||||
old_weight = _safe_optional_float((current.get("vendor") or {}).get("empty_spool_weight"))
|
||||
if old_weight is not None:
|
||||
spools_to_fix = [s for s in affected_spools if s.get("spool_weight") is None]
|
||||
if spools_to_fix:
|
||||
@@ -2118,7 +2122,7 @@ async def patch_spoolman_filament(
|
||||
_raise_if_partial_failure(affected_spools, results, "spool_weight stamp (option B)")
|
||||
else:
|
||||
# Filament weight is being cleared — remove any per-spool override
|
||||
# so spools fall back to whatever the filament now provides.
|
||||
# so spools fall back to the vendor's empty_spool_weight, or 250 g.
|
||||
spools_to_clear = [s for s in affected_spools if s.get("spool_weight") is not None]
|
||||
if spools_to_clear:
|
||||
async with _translate_spoolman_errors():
|
||||
|
||||
@@ -525,3 +525,113 @@ class TestUpdateSpoolWeightPriority:
|
||||
# remaining = 600 - 250 (fallback) = 350
|
||||
mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(350.0))
|
||||
assert response.json().get("warnings")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vendor-level tare (#3195): spool -> filament -> vendor.empty_spool_weight -> 250 g
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The reporter's numbers: Sunlu's empty_spool_weight is 211.7 g, the scale reads
|
||||
# 1177 g, and Spoolman's own /measure gives 965.3 g remaining.
|
||||
SUNLU_VENDOR = {"id": 5, "name": "Sunlu", "empty_spool_weight": 211.7}
|
||||
|
||||
SPOOL_WITH_VENDOR_TARE_ONLY = {
|
||||
**SAMPLE_SPOOL_WITH_FILAMENT_7,
|
||||
"spool_weight": None,
|
||||
"filament": {**SAMPLE_SPOOL_WITH_FILAMENT_7["filament"], "spool_weight": None, "vendor": SUNLU_VENDOR},
|
||||
}
|
||||
|
||||
|
||||
class TestVendorTare:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_sync_weight_uses_vendor_empty_spool_weight(self, async_client: AsyncClient, spoolman_settings):
|
||||
mock_client = make_mock_client()
|
||||
mock_client.get_spool = AsyncMock(return_value=SPOOL_WITH_VENDOR_TARE_ONLY)
|
||||
mock_client.update_spool_full = AsyncMock(return_value=SPOOL_WITH_VENDOR_TARE_ONLY)
|
||||
|
||||
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
||||
response = await async_client.patch(
|
||||
"/api/v1/spoolman/inventory/spools/42/weight",
|
||||
json={"weight_grams": 1177.0},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert mock_client.update_spool_full.call_args.kwargs["remaining_weight"] == pytest.approx(965.3)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_sync_weight_filament_tare_still_beats_vendor(self, async_client: AsyncClient, spoolman_settings):
|
||||
spool_data = {
|
||||
**SPOOL_WITH_VENDOR_TARE_ONLY,
|
||||
"filament": {**SPOOL_WITH_VENDOR_TARE_ONLY["filament"], "spool_weight": 180.0},
|
||||
}
|
||||
mock_client = make_mock_client()
|
||||
mock_client.get_spool = AsyncMock(return_value=spool_data)
|
||||
mock_client.update_spool_full = AsyncMock(return_value=spool_data)
|
||||
|
||||
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
||||
response = await async_client.patch(
|
||||
"/api/v1/spoolman/inventory/spools/42/weight",
|
||||
json={"weight_grams": 1177.0},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert mock_client.update_spool_full.call_args.kwargs["remaining_weight"] == pytest.approx(997.0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_spoolbuddy_scale_uses_vendor_tare_without_warning(
|
||||
self, async_client: AsyncClient, spoolman_settings
|
||||
):
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_spool = AsyncMock(return_value=SPOOL_WITH_VENDOR_TARE_ONLY)
|
||||
mock_client.update_spool = AsyncMock(return_value=None)
|
||||
|
||||
with patch(
|
||||
"backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
|
||||
AsyncMock(return_value=mock_client),
|
||||
):
|
||||
response = await async_client.post(
|
||||
"/api/v1/spoolbuddy/scale/update-spool-weight",
|
||||
json={"spool_id": 42, "weight_grams": 1177.0},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(965.3))
|
||||
# The vendor tare is a real value, so the 250 g fallback warning must not fire.
|
||||
assert not response.json().get("warnings")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_option_a_stamps_vendor_tare_when_filament_had_none(
|
||||
self, async_client: AsyncClient, spoolman_settings
|
||||
):
|
||||
"""Inheriting spools weighed against the vendor's tare before the filament
|
||||
got its own, so keeping them unchanged means stamping the vendor's value."""
|
||||
filament = {**SAMPLE_FILAMENT, "spool_weight": None, "vendor": SUNLU_VENDOR}
|
||||
mock_client = make_mock_client(filament=filament, all_spools=[SPOOL_WITH_VENDOR_TARE_ONLY])
|
||||
|
||||
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
||||
response = await async_client.patch(
|
||||
"/api/v1/spoolman/inventory/filaments/7",
|
||||
json={"spool_weight": 196.0, "keep_existing_spools": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_client.update_spool_full.assert_called_once_with(spool_id=42, spool_weight=pytest.approx(211.7))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_option_a_filament_tare_beats_vendor_tare(self, async_client: AsyncClient, spoolman_settings):
|
||||
filament = {**SAMPLE_FILAMENT, "spool_weight": 250.0, "vendor": SUNLU_VENDOR}
|
||||
mock_client = make_mock_client(filament=filament, all_spools=[SPOOL_WITH_VENDOR_TARE_ONLY])
|
||||
|
||||
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
|
||||
response = await async_client.patch(
|
||||
"/api/v1/spoolman/inventory/filaments/7",
|
||||
json={"spool_weight": 196.0, "keep_existing_spools": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_client.update_spool_full.assert_called_once_with(spool_id=42, spool_weight=pytest.approx(250.0))
|
||||
|
||||
@@ -8,6 +8,7 @@ from backend.app.api.routes._spoolman_helpers import (
|
||||
_map_spoolman_spool,
|
||||
_safe_float,
|
||||
_safe_int,
|
||||
spoolman_tare,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -466,6 +467,56 @@ class TestMapSpoolmanSpool:
|
||||
spool = {**MINIMAL_SPOOL, "spool_weight": None, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": None}}
|
||||
assert _map_spoolman_spool(spool)["core_weight"] == 250
|
||||
|
||||
def test_core_weight_from_vendor_empty_spool_weight(self):
|
||||
"""Spoolman's third tare level is the vendor's empty_spool_weight (#3195)."""
|
||||
spool = {
|
||||
**MINIMAL_SPOOL,
|
||||
"spool_weight": None,
|
||||
"filament": {**MINIMAL_SPOOL["filament"], "spool_weight": None, "vendor": {"empty_spool_weight": 211.7}},
|
||||
}
|
||||
result = _map_spoolman_spool(spool)
|
||||
assert result["core_weight"] == 211
|
||||
# Still inherited: the spool has no tare of its own, so the form must not stamp it.
|
||||
assert result["core_weight_is_inherited"] is True
|
||||
|
||||
|
||||
class TestSpoolmanTare:
|
||||
"""spoolman_tare resolves the tare the way Spoolman's /measure does (#3195)."""
|
||||
|
||||
@staticmethod
|
||||
def _spool(spool_level=None, filament_level=None, vendor_level=None, vendor=True):
|
||||
filament: dict = {"spool_weight": filament_level}
|
||||
if vendor:
|
||||
filament["vendor"] = {"id": 5, "name": "Sunlu", "empty_spool_weight": vendor_level}
|
||||
return {"id": 1, "spool_weight": spool_level, "filament": filament}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("levels", "expected"),
|
||||
[
|
||||
((300.0, 180.0, 211.7), (300.0, "spool")),
|
||||
((None, 180.0, 211.7), (180.0, "filament")),
|
||||
((None, None, 211.7), (211.7, "vendor")),
|
||||
((None, None, None), (250.0, "fallback")),
|
||||
],
|
||||
)
|
||||
def test_resolution_order(self, levels, expected):
|
||||
assert spoolman_tare(self._spool(*levels)) == expected
|
||||
|
||||
@pytest.mark.parametrize("level", ["spool", "filament", "vendor"])
|
||||
def test_zero_is_a_real_tare_at_every_level(self, level):
|
||||
levels = {"spool": (0, 180.0, 211.7), "filament": (None, 0, 211.7), "vendor": (None, None, 0)}[level]
|
||||
assert spoolman_tare(self._spool(*levels)) == (0.0, level)
|
||||
|
||||
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), "abc"])
|
||||
def test_non_finite_value_counts_as_missing(self, bad):
|
||||
assert spoolman_tare(self._spool(bad, None, 211.7)) == (211.7, "vendor")
|
||||
|
||||
def test_missing_vendor_null_vendor_and_null_filament(self):
|
||||
assert spoolman_tare(self._spool(vendor=False)) == (250.0, "fallback")
|
||||
assert spoolman_tare({"id": 1, "filament": {"vendor": None}}) == (250.0, "fallback")
|
||||
assert spoolman_tare({"id": 1, "filament": None}) == (250.0, "fallback")
|
||||
assert spoolman_tare({"id": 1}) == (250.0, "fallback")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F4: _safe_optional_float unit tests
|
||||
|
||||
Reference in New Issue
Block a user