Procházet zdrojové kódy

Fix manual spool weight overwritten by AMS auto-sync (#525)

Add weight_locked flag to spools that auto-sets when weight_used is
explicitly updated via the API. Both the MQTT AMS remain% auto-sync and
the manual force-sync endpoint skip locked spools. Usage tracker delta
tracking is unaffected. Users can re-enable AMS sync by setting
weight_locked to false.
maziggy před 3 měsíci
rodič
revize
37d9b4c841

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.1] - Unreleased
 ## [0.2.1] - Unreleased
 
 
 ### Fixed
 ### Fixed
+- **Manual Spool Weight Overwritten by AMS Auto-Sync** ([#525](https://github.com/maziggy/bambuddy/issues/525)) — When a user manually entered a spool weight (via UI or API), the value was overwritten by the automatic AMS remain% sync that runs on every MQTT update. The AMS remain% is integer-only (~10g resolution for 1kg spool) and can't match precise manual entries. Added a `weight_locked` flag that is automatically set when `weight_used` is explicitly updated via the API. Locked spools are skipped by both the automatic AMS remain% sync and the manual force-sync endpoint. The usage tracker (3MF/gcode delta tracking) is unaffected. Users can re-enable AMS sync by setting `weight_locked: false`.
 - **Inconsistent Print Cost on Reprints** ([#505](https://github.com/maziggy/bambuddy/issues/505)) — Reprinting the same model produced different costs each time (e.g., £0.77, £1.54, £2.03 for the same print). Three independent code paths wrote to `archive.cost` with conflicting strategies: the usage tracker summed ALL historical `SpoolUsageHistory` rows for the archive (including rows from previous reprints), and a separate `add_reprint_cost` method added yet another full print's cost on top. Removed the redundant `add_reprint_cost` path entirely and changed the usage tracker to compute cost only from the current print session's results instead of querying all historical rows. `archive.cost` now always reflects the cost of a single print.
 - **Inconsistent Print Cost on Reprints** ([#505](https://github.com/maziggy/bambuddy/issues/505)) — Reprinting the same model produced different costs each time (e.g., £0.77, £1.54, £2.03 for the same print). Three independent code paths wrote to `archive.cost` with conflicting strategies: the usage tracker summed ALL historical `SpoolUsageHistory` rows for the archive (including rows from previous reprints), and a separate `add_reprint_cost` method added yet another full print's cost on top. Removed the redundant `add_reprint_cost` path entirely and changed the usage tracker to compute cost only from the current print session's results instead of querying all historical rows. `archive.cost` now always reflects the cost of a single print.
 - **Timestamps Off by Timezone Offset in Non-UTC Docker Containers** ([#504](https://github.com/maziggy/bambuddy/issues/504)) — All backend timestamps used `datetime.now()` (server local time) or the deprecated `datetime.utcnow()`. The frontend's `parseUTCDate()` assumes timestamps without timezone indicators are UTC and appends `'Z'`, so when the container's timezone wasn't UTC, every stored timestamp was off by the timezone offset. Replaced all database and comparison timestamps with `datetime.now(timezone.utc)` across 16 backend files (~80 call sites). On the frontend, replaced 13 `new Date(backendTimestamp)` calls with `parseUTCDate()` across 8 files to correctly interpret UTC timestamps. Cosmetic timestamps (filenames, user-facing local time formatting) are intentionally left as local time.
 - **Timestamps Off by Timezone Offset in Non-UTC Docker Containers** ([#504](https://github.com/maziggy/bambuddy/issues/504)) — All backend timestamps used `datetime.now()` (server local time) or the deprecated `datetime.utcnow()`. The frontend's `parseUTCDate()` assumes timestamps without timezone indicators are UTC and appends `'Z'`, so when the container's timezone wasn't UTC, every stored timestamp was off by the timezone offset. Replaced all database and comparison timestamps with `datetime.now(timezone.utc)` across 16 backend files (~80 call sites). On the frontend, replaced 13 `new Date(backendTimestamp)` calls with `parseUTCDate()` across 8 files to correctly interpret UTC timestamps. Cosmetic timestamps (filenames, user-facing local time formatting) are intentionally left as local time.
 - **"Power Off Printer" Option Not Gated by Control Permission** ([#500](https://github.com/maziggy/bambuddy/issues/500)) — The "Power off printer when done" checkbox in the print modal and the auto power off toggle in the bulk edit modal were accessible to all users regardless of permissions. Users without the `printers:control` permission can now no longer enable auto power off — the checkbox and tri-state toggle are disabled and visually dimmed.
 - **"Power Off Printer" Option Not Gated by Control Permission** ([#500](https://github.com/maziggy/bambuddy/issues/500)) — The "Power off printer when done" checkbox in the print modal and the auto power off toggle in the bulk edit modal were accessible to all users regardless of permissions. Users without the `printers:control` permission can now no longer enable auto power off — the checkbox and tri-state toggle are disabled and visually dimmed.

+ 11 - 1
backend/app/api/routes/inventory.py

@@ -514,7 +514,12 @@ async def update_spool(
     if not spool:
     if not spool:
         raise HTTPException(404, "Spool not found")
         raise HTTPException(404, "Spool not found")
 
 
-    for field, value in spool_data.model_dump(exclude_unset=True).items():
+    update_data = spool_data.model_dump(exclude_unset=True)
+    # Auto-lock weight when user explicitly sets weight_used
+    if "weight_used" in update_data and "weight_locked" not in update_data:
+        update_data["weight_locked"] = True
+
+    for field, value in update_data.items():
         setattr(spool, field, value)
         setattr(spool, field, value)
 
 
     await db.commit()
     await db.commit()
@@ -1052,6 +1057,11 @@ async def sync_weights_from_ams(
             skipped += 1
             skipped += 1
             continue
             continue
 
 
+        if spool.weight_locked:
+            logger.debug("AMS weight sync: spool %d is weight-locked, skipping", spool.id)
+            skipped += 1
+            continue
+
         state = printer_manager.get_status(assignment.printer_id)
         state = printer_manager.get_status(assignment.printer_id)
         if not state or not state.raw_data:
         if not state or not state.raw_data:
             logger.info(
             logger.info(

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

@@ -1224,6 +1224,12 @@ async def run_migrations(conn):
     except OperationalError:
     except OperationalError:
         pass  # Already applied
         pass  # Already applied
 
 
+    # Migration: Add weight_locked flag to spool table (skip AMS auto-sync for manually-entered weights)
+    try:
+        await conn.execute(text("ALTER TABLE spool ADD COLUMN weight_locked BOOLEAN DEFAULT 0"))
+    except OperationalError:
+        pass  # Already applied
+
     # Migration: Add cost tracking fields to spool table
     # Migration: Add cost tracking fields to spool table
     try:
     try:
         await conn.execute(text("ALTER TABLE spool ADD COLUMN cost_per_kg REAL"))
         await conn.execute(text("ALTER TABLE spool ADD COLUMN cost_per_kg REAL"))

+ 5 - 1
backend/app/main.py

@@ -734,7 +734,11 @@ async def on_ams_change(printer_id: int, ams_data: list):
                             # The AMS remain% is low-resolution (integer %, i.e. 10g steps for 1kg spool)
                             # The AMS remain% is low-resolution (integer %, i.e. 10g steps for 1kg spool)
                             # and must not overwrite precise values from the usage tracker (3MF/G-code).
                             # and must not overwrite precise values from the usage tracker (3MF/G-code).
                             remain_raw = tray.get("remain")
                             remain_raw = tray.get("remain")
-                            if remain_raw is not None and existing_assignment.spool:
+                            if (
+                                remain_raw is not None
+                                and existing_assignment.spool
+                                and not existing_assignment.spool.weight_locked
+                            ):
                                 try:
                                 try:
                                     remain_val = int(remain_raw)
                                     remain_val = int(remain_raw)
                                 except (TypeError, ValueError):
                                 except (TypeError, ValueError):

+ 2 - 1
backend/app/models/spool.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 from datetime import datetime
 
 
-from sqlalchemy import DateTime, Float, Integer, String, func
+from sqlalchemy import Boolean, DateTime, Float, Integer, String, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
@@ -23,6 +23,7 @@ class Spool(Base):
         Integer
         Integer
     )  # Reference to spool_catalog entry for core weight
     )  # Reference to spool_catalog entry for core weight
     weight_used: Mapped[float] = mapped_column(Float, default=0)  # Consumed grams
     weight_used: Mapped[float] = mapped_column(Float, default=0)  # Consumed grams
+    weight_locked: Mapped[bool] = mapped_column(Boolean, default=False)  # Lock weight from AMS auto-sync
     slicer_filament: Mapped[str | None] = mapped_column(String(50))  # Preset ID (e.g. "GFL99")
     slicer_filament: Mapped[str | None] = mapped_column(String(50))  # Preset ID (e.g. "GFL99")
     slicer_filament_name: Mapped[str | None] = mapped_column(String(100))  # Preset name for slicer
     slicer_filament_name: Mapped[str | None] = mapped_column(String(100))  # Preset name for slicer
     nozzle_temp_min: Mapped[int | None] = mapped_column()  # Override min temp
     nozzle_temp_min: Mapped[int | None] = mapped_column()  # Override min temp

+ 2 - 0
backend/app/schemas/spool.py

@@ -23,6 +23,7 @@ class SpoolBase(BaseModel):
     data_origin: str | None = None
     data_origin: str | None = None
     tag_type: str | None = None
     tag_type: str | None = None
     cost_per_kg: float | None = Field(default=None, ge=0)
     cost_per_kg: float | None = Field(default=None, ge=0)
+    weight_locked: bool = False
 
 
 
 
 class SpoolCreate(SpoolBase):
 class SpoolCreate(SpoolBase):
@@ -54,6 +55,7 @@ class SpoolUpdate(BaseModel):
     data_origin: str | None = None
     data_origin: str | None = None
     tag_type: str | None = None
     tag_type: str | None = None
     cost_per_kg: float | None = Field(default=None, ge=0)
     cost_per_kg: float | None = Field(default=None, ge=0)
+    weight_locked: bool | None = None
 
 
 
 
 class SpoolKProfileBase(BaseModel):
 class SpoolKProfileBase(BaseModel):

+ 7 - 0
backend/tests/conftest.py

@@ -139,6 +139,13 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
         async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
         async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
             yield client
             yield client
 
 
+        # The app lifespan called init_db() which used the module-level engine
+        # (not the test engine), creating aiosqlite connections. Dispose those
+        # connections so their background threads finish before the event loop closes.
+        from backend.app.core.database import engine as real_engine
+
+        await real_engine.dispose()
+
     app.dependency_overrides.clear()
     app.dependency_overrides.clear()