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:
maziggy
2026-09-30 08:43:21 +02:00
parent d2a7a2d20f
commit c145b3ebc1
6 changed files with 215 additions and 20 deletions
+34 -4
View File
@@ -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,
+6 -7
View File
@@ -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)
+13 -9
View File
@@ -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():