Keep the consumed-counter reset out of Spoolman's own numbers (issue #2906) (#2939)

This commit is contained in:
Kouki Ojima
2026-09-28 09:58:16 +02:00
committed by GitHub
parent 6855d65d12
commit ff07a82358
5 changed files with 513 additions and 36 deletions
+70 -2
View File
@@ -158,6 +158,51 @@ def _extract_extra_str(extra: dict, key: str) -> str:
return decoded if isinstance(decoded, str) else ""
# Spool.extra key holding the consumed-counter baseline: the value Spoolman's
# used_weight had when the user last pressed "Reset usage to 0". Displayed
# consumed is used_weight minus this, which is what internal mode has always
# done with its own weight_used_baseline column (#1644). See #2906.
BAMBU_WEIGHT_USED_BASELINE_KEY = "bambu_weight_used_baseline"
def _extract_extra_float(extra: dict, key: str) -> float | None:
"""Extract a JSON-encoded number from a Spoolman extra dict.
Same storage convention as :func:`_extract_extra_str` — Spoolman keeps
extra values as JSON text, so 250.0 is stored as ``"250.0"``. Returns
``None`` for missing keys, non-finite values, or anything that does not
decode to a number, so the caller can tell "no baseline recorded" from
"a baseline of zero".
"""
raw = extra.get(key)
if raw is None:
return None
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
value = float(raw)
elif isinstance(raw, str):
try:
decoded = json.loads(raw)
except (json.JSONDecodeError, ValueError):
return None
if isinstance(decoded, str):
# Spoolman's extra fields default to field_type "text", which only
# accepts values that decode to a str, so numbers written through
# it arrive as '"263.0"' rather than '263.0'. Both spellings have
# to read back the same, or a baseline written by the reset would
# be invisible to the code that subtracts it.
try:
value = float(decoded)
except ValueError:
return None
elif isinstance(decoded, (int, float)) and not isinstance(decoded, bool):
value = float(decoded)
else:
return None
else:
return None
return value if math.isfinite(value) else None
def parse_spoolman_multi_colors(filament: dict) -> list[str]:
"""Spoolman's ``multi_color_hexes`` as a list of bare 6/8-char hex tokens.
@@ -251,14 +296,37 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
# When remaining_weight is unset (legacy spools, or filament linked but
# never primed), fall back to the old behaviour: weight_used =
# used_weight, baseline = 0.
#
# A "Reset usage to 0" records the then-current used_weight under
# BAMBU_WEIGHT_USED_BASELINE_KEY in spool.extra and touches nothing else,
# so displayed consumed is used_weight minus that. It is folded into the
# baseline here because the frontend's arithmetic is fixed: it always shows
# `weight_used - weight_used_baseline`. The reset used to PATCH Spoolman's
# used_weight to 0 instead, and Spoolman recomputes remaining from initial
# minus used, so the spool jumped back to full (#2906).
# `or 0.0` would collapse None and 0.0, which is the one distinction
# _extract_extra_float exists to preserve; spell the absent case out.
# Clamp the low end too: the write side already refuses a negative
# baseline, and without the same rule here a negative value stored by hand
# would be subtracted, inflating the displayed counter. min() below only
# bounds the other end.
stored = _extract_extra_float(extra, BAMBU_WEIGHT_USED_BASELINE_KEY)
stored_baseline = 0.0 if stored is None else max(0.0, stored)
remaining_raw = spool.get("remaining_weight")
if remaining_raw is not None:
remaining_weight: float = _safe_float(remaining_raw, 0.0)
used_weight: float = max(0.0, float(label_weight) - remaining_weight)
weight_used_baseline: float = max(0.0, used_weight - real_used_weight)
weight_used_baseline: float = max(0.0, used_weight - real_used_weight + stored_baseline)
else:
used_weight = real_used_weight
weight_used_baseline = 0.0
weight_used_baseline = stored_baseline # already clamped to >= 0 above
# Never let the displayed counter read negative. used_weight can fall below
# the baseline after a reset without anyone touching it: the AMS sync
# writes remaining_weight from the tray percentage, and Spoolman derives
# used_weight from that. The counter then reads 0 until consumption climbs
# past the baseline again. Internal mode behaves the same way, and it is
# far better than before #2906, when the next sync undid the reset outright.
weight_used_baseline = min(weight_used_baseline, used_weight)
# Archived state – Spoolman uses a boolean ``archived`` field
archived: bool = spool.get("archived", False)
+12 -9
View File
@@ -950,18 +950,21 @@ async def reset_spool_consumed_counter(
) -> dict:
"""Zero the displayed "Total Consumed" counter for a Spoolman spool.
Spoolman doesn't have a native "baseline" field, so the implementation
reaches for the closest equivalent: PATCH `used_weight=0` upstream.
The read mapping in ``_map_spoolman_spool`` then derives Bambuddy's
`weight_used = label - remaining_weight` and `baseline = weight_used -
real_used_weight`, so the Inventory page's `weight_used - baseline`
display lands at 0 while remaining (= label - weight_used) is preserved
— parity with the internal-mode endpoint (#1390, see also
Spoolman has no native baseline field, so the baseline lives in
``spool.extra`` — the same mechanism internal mode uses for its
``weight_used_baseline`` column. ``_map_spoolman_spool`` folds it back in,
so the Inventory page's ``weight_used - baseline`` reads 0 while every
native Spoolman field is left exactly as it was.
It used to PATCH ``used_weight = 0`` upstream, which is not the same thing:
Spoolman recomputes ``remaining_weight`` from initial minus used, so the
spool jumped back to full and the measured remaining filament was gone
(#2906). Parity with the internal-mode endpoint (#1644, #1390, see also
``backend/app/api/routes/inventory.py::reset_spool_consumed_counter``).
"""
client = await _get_client(db)
async with _translate_spoolman_errors():
spool = await client.reset_spool_usage(spool_id)
spool = await client.reset_spool_consumed_counter(spool_id)
try:
mapped = _map_spoolman_spool(spool)
except ValueError as exc:
@@ -1112,7 +1115,7 @@ async def bulk_reset_spool_consumed_counter(
for spool_id in spool_ids:
try:
async with _translate_spoolman_errors():
await client.reset_spool_usage(spool_id)
await client.reset_spool_consumed_counter(spool_id)
reset_count += 1
except HTTPException as exc:
logger.warning("Spoolman reset-consumed-counter failed for spool %s: %s", spool_id, exc.detail)