Draw a spool the way the AMS described it, and correct the tare it was added with

Two faults in the same auto-add path, both found while tracing why an H2C
        slot named a wood roll as plain PLA.

        A spool's swatch is composed from effect_type and extra_colors, and the
        RFID auto-add set neither. It reads the colour catalogue to name the
        colour and took the name alone, even though the row it had in hand also
        carries those two columns -- the spool form's own colour picker hands both
        to a spool a user adds by hand, so the same roll rendered one way when you
        typed it in and another when the printer identified it for you.

        Both columns now travel with the name. That alone changes nothing on a
        stock install, because the shipped catalogue carries an effect on none of
        its 600-odd rows, so the subtype is read where the catalogue has none: it
        is already derived from what the printer reports, and the two vocabularies
        line up -- Wood, Silk, Sparkle, Marble, Glow, Galaxy, Metal, Rainbow,
        Translucent, Matte, and the Gradient, Dual Color and Tri Color that the
        M*/T* colour codes upgrade a subtype to. "Silk+" reads as Silk, since the
        plus is on the product name rather than the finish. A subtype that names
        no effect -- Basic, Tough, CF -- leaves the column empty rather than
        inventing an overlay, and a value already set is never overwritten, so the
        column stays what it is documented to be: a rendering hint the user can
        override without touching Bambu's categorical label.

        ------

        Correct the spool tare an RFID roll was added with (#2909)

        The lookup that gave an auto-added spool its core_weight asked for the
        first catalogue row whose name starts "Bambu Lab" and took whatever came
        back. There are three, and which is first is the database's business:
        SQLite returns insertion order in practice, Postgres promises nothing once
        a table has seen an update. The same roll was therefore recorded with the
        216 g High Temp tare on one install and correctly with the 250 g Low Temp
        one on another. @ojimpo's forward fix picks the row by name; this repairs
        the rows already written, which the forward fix cannot reach -- 22 of 26
        RFID-added spools on the instance this was traced on.

        The tare is not cosmetic. A spool weighed on SpoolBuddy has its remaining
        filament worked out as the scale reading minus the tare, so a 34 g low
        tare credits the roll with 34 g that is not there and writes a used weight
        34 g short. That error is a constant -- every later print adds to the used
        weight on top of it -- so adding the difference back is exact however much
        has been printed since. It is applied only to spools that have been on the
        scale; one that never was has a used weight derived from the AMS remaining
        percentage, which the tare never entered into.

        Rows are identified by the signature of the broken lookup: added by RFID,
        carrying the weight of one of the other Bambu catalogue rows, with the
        weights read out of the catalogue rather than hardcoded so an install
        whose rows have been re-measured is repaired to its own numbers. Keying on
        whether a catalogue row had been recorded would not have worked -- the
        weight picker auto-selects the only row matching the weight and writes its
        id on the next save, so that column says only whether the form was ever
        opened. The one case that cannot be told apart is stated rather than
        hidden: someone who moved an RFID roll onto a genuine High Temp spool and
        set 216 g by hand is normalised with the rest. Runs exactly once, so a
        tare set afterwards is kept.
This commit is contained in:
maziggy
2026-08-26 10:13:42 +02:00
5 changed files with 515 additions and 0 deletions
+2
View File
@@ -19,6 +19,8 @@ All notable changes to Bambuddy will be documented in this file.
- **Generated thumbnails are lit, so one model no longer looks like the next (#2816, requested by @NaegeliJ, contributed by @sadontsev in #2861)** — Both renderers that draw a model themselves — the File Manager's STL/3MF thumbnails and the plate cards — handed matplotlib a mesh with no light source, and without one every triangle is filled with the identical green whichever way it faces. The result was a flat silhouette, so two models of similar outline were the same picture. The mesh is now shaded from a fixed light whose angle is pinned to the camera angle rather than chosen freely: the two are a pair, and a light aimed at the far side of the model gives both visible faces the same brightness and no contrast at all. Lighting then exposed two faults a flat render had hidden. A face wound the wrong way shades as though it faced away, so an STL with inconsistent winding came out patchy like camouflage; winding is now repaired before the render — outward rather than merely consistent, and only for the meshes that need it, since the check is milliseconds where the repair is seconds. And a mesh whose facets are all zero-area or collinear — stub or truncated STLs, 3MFs with an empty triangle list — would have failed outright once lit, so those are detected and still render flat instead of counting as a failure in a folder-wide batch. Both renderers share one copy of the light, the camera and the repair, so a plate card and a library thumbnail of the same model cannot drift apart. Passing the faces as an array rather than a list of lists also cut the collection build on an 82k-face mesh from ~0.19s to ~0.007s, which speeds up the unlit path too.
### Fixed
- **A wood, silk or gradient roll the AMS added for you was drawn as a flat disc** — A spool's swatch is composed from `effect_type` and `extra_colors`, and the RFID auto-add set neither. It reads the colour catalogue to name the colour and took the name alone, even though the row it had in hand also carries those two columns — the spool form's own colour picker hands both to a spool a user adds by hand, so the same roll rendered one way when you typed it in and another when the printer identified it for you. Both columns now travel with the name. That alone would have changed nothing on a stock install, because the shipped catalogue carries an effect on none of its 600-odd rows, so the subtype is read where the catalogue has none: it is already derived from what the printer reports, and the two vocabularies line up — Wood, Silk, Sparkle, Marble, Glow, Galaxy, Metal, Rainbow, Translucent, Matte, and the Gradient, Dual Color and Tri Color that the M*/T* colour codes upgrade a subtype to. "Silk+" is read as Silk, since the plus is on the product name rather than the finish. A subtype that names no effect — Basic, Tough, CF — leaves the column empty rather than inventing an overlay, and a value already set is never overwritten, so the column stays what it is documented to be: a rendering hint the user can override without touching Bambu's categorical label.
- **The spool tare an RFID roll was added with is corrected on upgrade (#2909, diagnosed by @ojimpo)** — The lookup that gave an auto-added spool its `core_weight` asked for the first catalogue row whose name starts "Bambu Lab" and took whatever came back. There are three, and which is first is the database's business: SQLite returns insertion order in practice, Postgres promises nothing once a table has seen an update, so the same roll was recorded with the 216 g High Temp tare on one install and correctly with the 250 g Low Temp one on another. The forward fix picks the row by name; this repairs the rows already written, which the forward fix cannot reach. The tare is not cosmetic: a spool weighed on SpoolBuddy has its remaining filament worked out as the scale reading minus the tare, so a 34 g low tare credits the roll with 34 g that is not there and writes a used weight 34 g short. That error is a constant — every later print adds to the used weight on top of it — so adding the difference back is exact however much has been printed since, and it is applied only to spools that have actually been on the scale; one that never was has a used weight derived from the AMS remaining percentage, which the tare never entered into. The rows to repair are identified by the signature of the broken lookup — added by RFID, carrying the weight of one of the *other* Bambu catalogue rows — with the weights read out of the catalogue rather than hardcoded, so an install whose rows have been re-measured is repaired to its own numbers. One case cannot be told apart and is stated rather than hidden: someone who moved an RFID roll onto a genuine High Temp spool and set 216 g by hand looks identical to a row the lookup got wrong and is normalised with them. Keying on whether a catalogue row had been recorded would not have rescued them either — the weight picker auto-selects the only row matching the weight and writes its id on the next save, so that column says only whether the form was ever opened. Runs exactly once, so a tare set afterwards is kept.
- **A wood-filled spool was named as plain PLA on the slot it was assigned to** — A spool's subtype is half of what it is called: "PLA" and "PLA Wood" are different filaments, and the AMS slot's hover card built the assigned-spool line out of brand, material and colour name with the subtype left out. A roll of Bambu PLA Wood Classic Birch in an H2C's A4 was therefore announced as "Bambu Lab PLA - Classic Birch". Everything else named it correctly at the same moment — the RFID read, the inventory row, the slot's own profile line, which is built from the spool's slicer preset rather than reassembled, and Bambu Studio — so the one wrong line read like a bad tag read rather than a display fault. It was not only the render: the card's `assignedSpool` prop had no subtype field at all, and the six places the printer card fills it in (regular AMS, AMS-HT and external spool, each in both Spoolman and internal-inventory mode) never passed one, so the value could not reach the component. The field is required now rather than optional, which is what stops the next call site from quietly omitting it — that omission is the whole of this bug. Three more surfaces were rebuilding the name the same way and are fixed with it: the SpoolBuddy AMS slot panel in both inventory modes, and the write-tag confirmation. Every other place a spool is named — the assign dialogs, the inventory cards, the forecast rows, the label picker — already included the subtype, so these four were the outliers. This is the display-side half of #2902, which stopped the backend reducing a filled or foamed filament onto its base material; the card was doing the same thing to the same spools, one layer further out.
- **The bundled chamber-preheat table was unreadable to the code that reads it** — every lookup of the per-filament chamber map happens after the keys are upper-cased, but an install that had never opened the setting got the bundled table back exactly as declared, with its lowercase `default` row. The scheduler then looked for `DEFAULT`, found nothing, and used a hardcoded 0 for any filament without a row of its own. It reported the right number only because that bundled default is 0 — raising it would have silently changed nothing for everyone who had not customised the map. Both paths out of the parser now honour the one contract it documents.
- **The archives API never reported which plate was printed (#2796, contributed by @sgiffhorn)** — `archive_to_response()` builds the archives response field by field and had no line for `plate_id`, so `GET /archives/`, the detail endpoint, search, PATCH and the project archive list all answered `plate_id: null` — for archives whose column was populated as well. `ArchiveResponse.plate_id` defaults to `None`, so Pydantic filled the null in without complaint and nothing ever raised. The column has been written since #2603 and is backfilled from the queue on startup, so the plate was recorded all along and simply could not be read back out; on the reporting instance 181 of 257 rows carry one. Archive cards now name the plate again — but only when it is not the first one. The queue records a plate for single-plate files too, because the print dialog auto-selects the only plate there is, so labelling every archive that has a `plate_id` would have put "Plate 1" on most cards in the grid and taken room from the truncated print name. A multi-plate archive printed from its first plate still identifies itself through the plate carousel, which has always gated on whether the source 3MF holds more than one plate.
+122
View File
@@ -4427,6 +4427,128 @@ async def run_migrations(conn):
conn, "ALTER TABLE notification_providers ADD COLUMN on_ams_drying_suspended BOOLEAN DEFAULT TRUE"
)
# Migration: repair the tare of spools the RFID auto-add gave the wrong
# Bambu spool row (#2909). Runs last so the spool catalogue it reads is
# whatever this database actually holds.
await _migrate_repair_rfid_core_weight(conn)
async def _migrate_repair_rfid_core_weight(conn) -> None:
"""Correct the tare of RFID-added spools that took the wrong catalogue row (#2909).
A Bambu roll arrives on the 250 g Low Temp spool, but the lookup that gave
an auto-added spool its ``core_weight`` asked for the first row whose name
starts "Bambu Lab" and took whatever came back. There are three, and which
one is first is up to the database: SQLite returns insertion order in
practice, Postgres promises nothing once the table has seen an update. So
the same roll could be recorded with a 216 g High Temp tare on one install
and correctly on another, and the reporting instance here got 216.
The tare is not cosmetic. A spool weighed on SpoolBuddy has its remaining
filament worked out as ``scale reading - core_weight``, so a 34 g low tare
credits the roll with 34 g of filament that is not there and writes a
``weight_used`` 34 g short. That error is a constant: every later print
adds to ``weight_used`` on top of it, so adding the difference back is an
exact repair however much has been printed since. Only rows that have
actually been weighed carry it -- a spool that was never on the scale has
a ``weight_used`` derived from the AMS remaining percentage, which the tare
never touched.
Which rows: ``data_origin = 'rfid_auto'`` narrows it to spools this code
path created, and a ``core_weight`` matching one of the *other* Bambu
catalogue rows is the signature of the broken lookup. Reading the weights
out of the catalogue rather than hardcoding 216 and 253 keeps it correct on
an install whose catalogue has been edited.
One case cannot be told apart and is stated rather than hidden: a user who
moved an RFID roll onto a genuine High Temp spool and set its tare to 216
by hand looks identical to a row the lookup got wrong, and is normalised
with them. Keying on ``core_weight_catalog_id IS NULL`` instead would not
have rescued them -- the spool form's weight picker auto-selects the only
catalogue row matching the weight and writes its id on the next save, so
that column says only whether the form was ever opened.
Gated to run exactly once via a settings flag, so a user who deliberately
sets one of these tares afterwards keeps it.
"""
from sqlalchemy import bindparam, text
flag = "_backfill_2909_rfid_core_weight_done"
async with conn.begin_nested():
already = (
await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
).scalar_one_or_none()
if already:
return
# The row that names the spool an RFID roll actually arrives on. Its
# absence is not an error -- a catalogue the user has pruned still gets
# the documented default.
correct = (
await conn.execute(
text("SELECT id, weight FROM spool_catalog WHERE UPPER(name) = :name ORDER BY id LIMIT 1"),
{"name": "BAMBU LAB - PLASTIC LOW TEMP"},
)
).fetchone()
correct_id = correct[0] if correct else None
correct_weight = correct[1] if correct else 250
bambu_weights = {
row[0]
for row in (
await conn.execute(
text("SELECT weight FROM spool_catalog WHERE UPPER(name) LIKE :prefix"),
{"prefix": "BAMBU LAB%"},
)
).fetchall()
}
wrong_weights = sorted(bambu_weights - {correct_weight})
repaired = 0
reweighed = 0
if wrong_weights:
rows = (
await conn.execute(
text(
"SELECT id, core_weight, label_weight, weight_used, last_weighed_at FROM spool "
"WHERE data_origin = 'rfid_auto' AND core_weight IN :wrong"
).bindparams(bindparam("wrong", expanding=True)),
{"wrong": wrong_weights},
)
).fetchall()
for row in rows:
delta = correct_weight - row.core_weight
weight_used = row.weight_used or 0.0
if row.last_weighed_at is not None:
weight_used = min(max(0.0, weight_used + delta), float(row.label_weight or 0))
reweighed += 1
await conn.execute(
text(
"UPDATE spool SET core_weight = :cw, core_weight_catalog_id = :cid, "
"weight_used = :wu WHERE id = :id"
),
{"cw": correct_weight, "cid": correct_id, "wu": weight_used, "id": row.id},
)
repaired += 1
if repaired:
logger.info(
"[#2909] Corrected the spool tare on %d RFID-added spool(s) to %d g; "
"%d of them had been weighed and had their used weight adjusted with it",
repaired,
correct_weight,
reweighed,
)
# Marked done even when nothing matched, so the one-shot never reopens
# a tare the user has since set for themselves.
await conn.execute(
text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
{"k": flag, "v": "true"},
)
async def _migrate_backfill_variant_groups(conn) -> None:
"""Build variant groups from the slice provenance already on disk (#671 / #2570).
+34
View File
@@ -8,6 +8,7 @@ from sqlalchemy.orm import selectinload
from backend.app.models.spool import Spool
from backend.app.models.spool_assignment import SpoolAssignment
from backend.app.schemas.spool import normalize_effect_type
from backend.app.utils.tag_normalization import (
normalize_tag_uid as _normalize_tag_uid,
normalize_tray_uuid as _normalize_tray_uuid,
@@ -99,6 +100,8 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
# PLA Basic happens to come first in catalog insertion order. See #1227.
rgba = tray_color if tray_color else None
color_name = None
extra_colors = None
effect_type = None
# Transparent filament (#1545): the AMS reports alpha=00 for clear spools.
# Skip the catalog lookup — the catalog only stores RGB so 000000 would
@@ -124,6 +127,13 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
entry = cat_result.scalar_one_or_none()
if entry:
color_name = entry.color_name
# The same row the spool form's colour picker reads. It hands
# `extra_colors` and `effect_type` to the new spool when a user
# picks a colour by hand (ColorSection.selectColor), and this path
# was taking the name alone -- so a roll added by hand rendered
# its gradient and a roll the AMS identified for you did not.
extra_colors = entry.extra_colors
effect_type = entry.effect_type
# If tray_id_name is a human-readable name (no "-" code), fall back to it.
if not color_name and tray_id_name and "-" not in tray_id_name:
@@ -136,6 +146,28 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
color_name,
)
# Fall back to the subtype for the swatch's rendering hint. `effect_type`
# is a visual variant kept independent of `subtype` so a user can override
# how a roll is drawn without touching Bambu's categorical label -- but
# nothing had ever set it here, and the shipped colour catalogue carries no
# effect on any of its 600-odd rows, so in practice it was always NULL and
# every wood, silk, sparkle and gradient roll drew as a flat disc. The
# subtype is the answer where the catalogue has none: it is derived above
# from what the printer reports, and the two vocabularies already line up
# ("Wood", "Silk", "Dual Color" are values of both). A subtype that names
# no effect -- Basic, Tough, CF -- leaves it NULL, which is the honest
# answer rather than a guessed overlay.
# "Silk+" is the same finish as "Silk" with a plus on the product name, so
# the trailing sign is dropped on a second attempt rather than costing the
# roll its overlay.
if effect_type is None and subtype:
for candidate in (subtype, subtype.rstrip("+")):
try:
effect_type = normalize_effect_type(candidate)
except ValueError:
continue
break
# Look up core weight from spool catalog
core_weight = 250 # Default for Bambu Lab plastic spools
cat_result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.name.ilike("Bambu Lab%")).limit(10))
@@ -173,6 +205,8 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
subtype=subtype,
color_name=color_name,
rgba=rgba,
extra_colors=extra_colors,
effect_type=effect_type,
brand="Bambu Lab",
label_weight=label_weight,
core_weight=core_weight,
@@ -111,6 +111,109 @@ async def test_create_spool_from_tray_relationships_loaded(db_session):
assert spool.assignments == []
# -- create_spool_from_tray: swatch rendering ------------------------------
#
# `effect_type` and `extra_colors` drive how a roll is drawn in the inventory
# and on the slot card. Nothing set either here, and the shipped colour
# catalogue carries an effect on none of its rows, so every auto-created wood,
# silk, sparkle and gradient roll came out as a flat disc.
@pytest.mark.asyncio
async def test_create_spool_from_tray_takes_effect_from_subtype(db_session):
"""A wood-filled roll is drawn as wood, not as a flat disc.
The catalogue row that names the colour has no effect of its own -- none
of the shipped rows do -- so the subtype the printer reported is what the
swatch has to be read from.
"""
db_session.add(
ColorCatalogEntry(
manufacturer="Bambu Lab",
color_name="Classic Birch",
hex_color="#918669",
material="PLA Wood",
is_default=True,
)
)
await db_session.flush()
spool = await create_spool_from_tray(
db_session,
{**SAMPLE_TRAY, "tray_sub_brands": "PLA Wood", "tray_color": "918669FF", "tray_id_name": "A16-G0"},
)
assert spool.subtype == "Wood"
assert spool.color_name == "Classic Birch"
assert spool.effect_type == "wood"
@pytest.mark.asyncio
async def test_create_spool_from_tray_prefers_the_catalogue_row_over_the_subtype(db_session):
"""A catalogue row that does carry rendering columns is what gets used.
This is the same row the spool form's colour picker reads, and it hands
both columns to a spool a user adds by hand; taking only the name here is
what made an RFID roll render differently from a hand-added one.
"""
db_session.add(
ColorCatalogEntry(
manufacturer="Bambu Lab",
color_name="Dawn Radiance",
hex_color="#FF0000",
material="PLA Basic",
extra_colors="00ff00,0000ff",
effect_type="marble",
is_default=True,
)
)
await db_session.flush()
spool = await create_spool_from_tray(
db_session,
{**SAMPLE_TRAY, "tray_color": "FF0000FF"},
)
# Subtype "Basic" names no effect, so the catalogue is the only source.
assert spool.subtype == "Basic"
assert spool.effect_type == "marble"
assert spool.extra_colors == "00ff00,0000ff"
@pytest.mark.asyncio
async def test_create_spool_from_tray_leaves_a_plain_subtype_alone(db_session):
"""Basic, Tough, CF and the rest name no effect and must not invent one."""
spool = await create_spool_from_tray(db_session, SAMPLE_TRAY)
assert spool.subtype == "Basic"
assert spool.effect_type is None
assert spool.extra_colors is None
@pytest.mark.asyncio
async def test_create_spool_from_tray_reads_silk_plus_as_silk(db_session):
"""Silk+ is the Silk finish with a plus on the product name."""
spool = await create_spool_from_tray(
db_session,
{**SAMPLE_TRAY, "tray_sub_brands": "PLA Silk+"},
)
assert spool.subtype == "Silk+"
assert spool.effect_type == "silk"
@pytest.mark.asyncio
async def test_create_spool_from_tray_reads_a_gradient_roll_as_a_gradient(db_session):
"""The M*/T* upgrade already rewrites the subtype; the effect follows it."""
spool = await create_spool_from_tray(
db_session,
{**SAMPLE_TRAY, "tray_id_name": "A00-M0"},
)
assert spool.subtype == "Gradient"
assert spool.effect_type == "gradient"
# -- get_spool_by_tag -------------------------------------------------------
@@ -0,0 +1,254 @@
"""Repair for RFID-added spools that took the wrong Bambu spool tare (#2909).
A Bambu roll arrives on the 250 g Low Temp spool, but the lookup that gave an
auto-added spool its ``core_weight`` took the first row named "Bambu Lab" in
whatever order the database returned, of which there are three. The forward fix
picks the row by name; ``_migrate_repair_rfid_core_weight`` corrects the rows
already written, and the ``weight_used`` a wrong tare put out with them.
"""
from datetime import datetime
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import backend.app.models # noqa: F401 - populate Base.metadata
from backend.app.core.database import Base, _migrate_repair_rfid_core_weight
from backend.app.models.spool import Spool
from backend.app.models.spool_catalog import SpoolCatalogEntry
FLAG = "_backfill_2909_rfid_core_weight_done"
# The three rows the broken lookup could return, at their seeded weights.
HIGH_TEMP = 216
LOW_TEMP = 250
WHITE = 253
@pytest.fixture
async def engine(tmp_path):
eng = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/t.db")
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
try:
yield eng
finally:
await eng.dispose()
async def _seed_catalog(db):
db.add_all(
[
SpoolCatalogEntry(name="Bambu Lab - Plastic High Temp", weight=HIGH_TEMP),
SpoolCatalogEntry(name="Bambu Lab - Plastic Low Temp", weight=LOW_TEMP),
SpoolCatalogEntry(name="Bambu Lab - Plastic White", weight=WHITE),
SpoolCatalogEntry(name="eSUN - Plastic", weight=240),
]
)
await db.flush()
def _spool(**kw):
base = {
"material": "PLA",
"brand": "Bambu Lab",
"label_weight": 1000,
"core_weight": HIGH_TEMP,
"weight_used": 0.0,
"data_origin": "rfid_auto",
}
base.update(kw)
return Spool(**base)
async def _run(engine):
async with engine.begin() as conn:
await _migrate_repair_rfid_core_weight(conn)
@pytest.mark.asyncio
async def test_corrects_the_tare_of_a_wrongly_added_spool(engine):
sm = async_sessionmaker(engine, expire_on_commit=False)
async with sm() as db:
await _seed_catalog(db)
s = _spool()
db.add(s)
await db.commit()
spool_id = s.id
catalog_id = (
await db.execute(text("SELECT id FROM spool_catalog WHERE weight = :w"), {"w": LOW_TEMP})
).scalar_one()
await _run(engine)
async with sm() as db:
fixed = await db.get(Spool, spool_id)
assert fixed.core_weight == LOW_TEMP
# The picker's own row, so the spool form shows the tare it now has.
assert fixed.core_weight_catalog_id == catalog_id
@pytest.mark.asyncio
async def test_adds_back_the_filament_a_wrong_tare_took_off_a_weighed_spool(engine):
"""A 34 g low tare wrote a ``weight_used`` 34 g short. That error is a
constant, so adding the difference back is exact however much has been
printed since the weighing."""
sm = async_sessionmaker(engine, expire_on_commit=False)
async with sm() as db:
await _seed_catalog(db)
# Weighed at 800 g with a 216 g tare: 584 g of filament read, so
# weight_used = 1000 - 584 = 416. The truth is 800 - 250 = 550 left,
# i.e. 450 used.
weighed = _spool(weight_used=416.0, last_scale_weight=800.0, last_weighed_at=datetime(2026, 8, 23, 11, 0))
db.add(weighed)
await db.commit()
weighed_id = weighed.id
await _run(engine)
async with sm() as db:
fixed = await db.get(Spool, weighed_id)
assert fixed.core_weight == LOW_TEMP
assert fixed.weight_used == 450.0
@pytest.mark.asyncio
async def test_leaves_the_used_weight_of_a_never_weighed_spool_alone(engine):
"""Its ``weight_used`` came from the AMS remaining percentage, which the
tare never entered into."""
sm = async_sessionmaker(engine, expire_on_commit=False)
async with sm() as db:
await _seed_catalog(db)
s = _spool(weight_used=200.0)
db.add(s)
await db.commit()
spool_id = s.id
await _run(engine)
async with sm() as db:
fixed = await db.get(Spool, spool_id)
assert fixed.core_weight == LOW_TEMP
assert fixed.weight_used == 200.0
@pytest.mark.asyncio
async def test_leaves_spools_the_user_added_alone(engine):
"""Only ``rfid_auto`` rows came through the lookup that got this wrong."""
sm = async_sessionmaker(engine, expire_on_commit=False)
async with sm() as db:
await _seed_catalog(db)
manual = _spool(data_origin="manual", weight_used=100.0)
db.add(manual)
await db.commit()
manual_id = manual.id
await _run(engine)
async with sm() as db:
untouched = await db.get(Spool, manual_id)
assert untouched.core_weight == HIGH_TEMP
assert untouched.weight_used == 100.0
@pytest.mark.asyncio
async def test_leaves_a_tare_that_is_not_a_bambu_row_alone(engine):
"""A third-party or hand-typed tare is not the broken lookup's signature."""
sm = async_sessionmaker(engine, expire_on_commit=False)
async with sm() as db:
await _seed_catalog(db)
third_party = _spool(core_weight=240) # the eSUN row
odd = _spool(core_weight=137) # typed by hand
db.add_all([third_party, odd])
await db.commit()
ids = (third_party.id, odd.id)
await _run(engine)
async with sm() as db:
assert (await db.get(Spool, ids[0])).core_weight == 240
assert (await db.get(Spool, ids[1])).core_weight == 137
@pytest.mark.asyncio
async def test_reads_the_right_weight_from_an_edited_catalogue(engine):
"""The weights are read out of the catalogue, not hardcoded, so an install
whose rows have been re-measured is repaired to its own numbers."""
sm = async_sessionmaker(engine, expire_on_commit=False)
async with sm() as db:
db.add_all(
[
SpoolCatalogEntry(name="Bambu Lab - Plastic High Temp", weight=210),
SpoolCatalogEntry(name="Bambu Lab - Plastic Low Temp", weight=247),
]
)
await db.flush()
s = _spool(core_weight=210)
db.add(s)
await db.commit()
spool_id = s.id
await _run(engine)
async with sm() as db:
assert (await db.get(Spool, spool_id)).core_weight == 247
@pytest.mark.asyncio
async def test_runs_exactly_once(engine):
"""A tare the user sets after the repair has run is theirs to keep."""
sm = async_sessionmaker(engine, expire_on_commit=False)
async with sm() as db:
await _seed_catalog(db)
s = _spool()
db.add(s)
await db.commit()
spool_id = s.id
await _run(engine)
async with sm() as db:
deliberate = await db.get(Spool, spool_id)
deliberate.core_weight = HIGH_TEMP
await db.commit()
await _run(engine)
async with sm() as db:
assert (await db.get(Spool, spool_id)).core_weight == HIGH_TEMP
@pytest.mark.asyncio
async def test_marks_itself_done_on_an_install_with_nothing_to_repair(engine):
"""Otherwise the scan would repeat on every boot for the rest of time."""
sm = async_sessionmaker(engine, expire_on_commit=False)
async with sm() as db:
await _seed_catalog(db)
await db.commit()
await _run(engine)
async with sm() as db:
done = (await db.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": FLAG})).scalar_one_or_none()
assert done == "true"
@pytest.mark.asyncio
async def test_survives_a_catalogue_with_no_bambu_rows(engine):
"""A pruned catalogue gets the documented 250 g default rather than a crash."""
sm = async_sessionmaker(engine, expire_on_commit=False)
async with sm() as db:
db.add(SpoolCatalogEntry(name="eSUN - Plastic", weight=240))
await db.flush()
s = _spool()
db.add(s)
await db.commit()
spool_id = s.id
await _run(engine)
async with sm() as db:
# No Bambu rows means no signature to match, so the spool is left as it
# stands -- guessing at it would be worse than the tare it has.
assert (await db.get(Spool, spool_id)).core_weight == HIGH_TEMP