Преглед изворни кода

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.
maziggy пре 1 недеља
родитељ
комит
8c01e0f8ea

Разлика између датотеке није приказан због своје велике величине
+ 1 - 0
CHANGELOG.md


+ 122 - 0
backend/app/core/database.py

@@ -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 - 0
backend/app/services/spool_tag_matcher.py

@@ -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,

+ 103 - 0
backend/tests/unit/services/test_spool_tag_matcher.py

@@ -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 -------------------------------------------------------
 
 

+ 254 - 0
backend/tests/unit/test_rfid_core_weight_repair_2909.py

@@ -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

Неке датотеке нису приказане због велике количине промена