Просмотр исходного кода

fix: prevent spool weight corruption, add AMS weight sync, fix extruder_id

Three bugs fixed and one recovery feature added:

1. AMS remain=0 zeroed all spools on printer power-off. The weight sync
   treated 0% remain as "fully consumed," setting weight_used to
   label_weight for every assigned spool. Fix: skip remain=0 in AMS
   weight sync — empty spools are tracked by the usage tracker.

2. Editing any spool field sent stale weight_used from the React Query
   cache back to the server, resetting usage-tracked weight. Fix: only
   include weight_used in PATCH when the user explicitly changes it.

3. K-profile auto-select crashed on dual-nozzle printers for non-BL
   spools with 'SpoolKProfile has no attribute extruder_id'. The model
   attribute is 'extruder', not 'extruder_id'.

4. New "Sync Weights from AMS" button in Settings > Filament Tracking
   (built-in inventory mode) to force-recover spool weights from live
   AMS sensor data. Bypasses the "only increase" guard for explicit
   user-initiated recovery.
maziggy 3 месяцев назад
Родитель
Сommit
be569f7577

+ 3 - 0
CHANGELOG.md

@@ -16,6 +16,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **AMS Slot Configuration — Model Filtering & Pre-Population** — The Configure AMS Slot modal now filters filament presets by the connected printer model. Only presets matching the printer (e.g., "@BBL X1C" presets for X1C printers) and generic presets without a model suffix are shown. Local presets are filtered by their `compatible_printers` field. When re-configuring an already-configured slot, the modal pre-selects the saved preset, pre-populates the color, and auto-selects the active K-profile. The preset list auto-scrolls to the selected item. All modal strings are now fully translated in 5 locales (en, de, fr, it, ja).
 - **K-Profiles View — Accurate Filament Name Resolution** — K-profile filament names are now resolved from builtin filament tables and user cloud presets (via new `/cloud/filament-id-map` endpoint) instead of showing raw IDs like "GFU99" or "P4d64437". Falls back to extracting names from the profile name field.
 - **Print Log** — New view mode on the Archives page showing a chronological table of all print activity. Columns include date/time, print name, printer, user, status, duration, and filament. Supports filtering by search text, printer, user, status, and date range. Pagination with configurable page size. A dedicated clear button deletes only log entries without affecting archives. Data is stored in a separate `print_log_entries` database table.
+- **Sync Spool Weights from AMS** — New button in Settings → Filament Tracking (built-in inventory mode) to force-sync all inventory spool weights from the live AMS remain% values of connected printers. Overwrites the database weight data with current sensor readings. Useful for recovering from corrupted weight data (e.g., after a power-off event zeroed all fill levels). Requires printers to be online. Includes a confirmation modal.
 
 ### Fixed
 - **Firmware Upload Uses Wrong Filename on Cache Hit** — The firmware update uploader cached downloaded firmware files under a mangled name (e.g., `X1C_01_09_00_10.bin`) instead of the original filename from Bambu Lab's CDN. On the first download the correct filename was uploaded to the SD card, but on subsequent attempts the cached file with the wrong name was used — causing the printer to not recognize the firmware file. Now caches using the original filename so the SD card always receives the correct file.
@@ -33,7 +34,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **Archive List View Not Labeling Failed Prints** ([#365](https://github.com/maziggy/bambuddy/issues/365)) — The archive grid view displayed a red "Failed" / "Cancelled" badge on failed and aborted prints, but the list view had no equivalent indicator. Now shows an inline status badge next to the print name in list view.
 - **Reprint Fails with SD Card Error for Archives Without 3MF File** ([#376](https://github.com/maziggy/bambuddy/issues/376)) — When a print was sent from an external slicer and Bambuddy couldn't download the 3MF from the printer during auto-archiving, the fallback archive had no file. Attempting to reprint such an archive tried to upload the data directory as a file, causing a confusing "SD card error." The backend now returns a clear error for file-less archives, and the frontend disables Print/Schedule/Open in Slicer buttons with a tooltip explaining that the 3MF file is unavailable.
 - **Inventory Spool Weight Resets After Print Completes** — After a print, the usage tracker correctly updated `weight_used` (e.g., +1.6g), but periodic AMS status updates recalculated `weight_used` from the AMS remain% sensor and overwrote the precise value. For small prints on large spools (e.g., 1.6g on 1000g), the AMS remain% stays at 100% (integer resolution = 10g steps), resetting `weight_used` back to 0. The AMS weight sync now only increases `weight_used`, never decreases it, preserving precise values from the usage tracker.
+- **All Spool Fill Levels Drop to Zero When Printers Power Off** — When a printer powers off, the AMS sensor can report `remain=0` for all trays while `tray_type` is still populated. The weight sync treated 0% remain as "100% consumed," computing `weight_used = label_weight` (e.g., 1000g). The "only increase" guard passed because `label_weight > current_used + 1`, marking every assigned spool as fully consumed. The AMS weight sync now skips `remain=0` entirely — a physically empty spool is tracked by the usage tracker during the print, not by a transient AMS sensor reading.
 - **Spool Edit Form Overwrites Usage-Tracked Weight** — Editing any spool field (note, color, material, etc.) sent the full form data back to the server, including `weight_used`. If the frontend cache was stale (e.g., loaded before the last print completed), saving the form would silently reset `weight_used` to the pre-print value, reverting the remaining weight to full. The form now only includes `weight_used` in the update request when the user explicitly changes the weight field.
+- **K-Profile Auto-Select Fails for Non-BL Spools on Dual-Nozzle Printers** — When assigning a third-party spool to an AMS slot on dual-nozzle printers (H2D, H2D Pro), the MQTT auto-configure step crashed with `'SpoolKProfile' object has no attribute 'extruder_id'`. The K-profile model uses `extruder` (not `extruder_id`). Fixed the attribute name so K-profile matching correctly filters by nozzle on dual-extruder printers.
 - **Loose Archive Name Matching Could Cause Wrong Archive Reuse** ([#374](https://github.com/maziggy/bambuddy/issues/374)) — The `on_print_start` callback used `ilike('%{name}%')` to find existing "printing" archives, which meant a print named "Clip" could incorrectly match "Cable Clip" or "Clip Stand". This could cause a new print to reuse the wrong archive or skip creating one. Tightened to exact `print_name` match or exact filename variants (`.3mf`, `.gcode.3mf`).
 - **Archive Duplicate Badge Misses Name-Based Duplicates** ([#315](https://github.com/maziggy/bambuddy/issues/315)) — The duplicate badge on archive cards only matched by file content hash, so re-sliced prints of the same model (different GCODE, same print name) were not flagged as duplicates. Now also matches by print name (case-insensitive), consistent with the detail view's duplicate detection.
 

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

@@ -748,7 +748,7 @@ async def assign_spool(
             matching_kp = None
             for kp in spool.k_profiles:
                 if kp.printer_id == data.printer_id and kp.nozzle_diameter == nozzle_diameter:
-                    if slot_extruder is not None and kp.extruder_id is not None and kp.extruder_id != slot_extruder:
+                    if slot_extruder is not None and kp.extruder is not None and kp.extruder != slot_extruder:
                         continue
                     matching_kp = kp
                     break
@@ -954,6 +954,99 @@ async def clear_spool_usage_history(
     return {"status": "cleared"}
 
 
+# ── AMS Weight Sync ──────────────────────────────────────────────────────────
+
+
+@router.post("/sync-ams-weights")
+async def sync_weights_from_ams(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Force-sync spool weight_used from live AMS remain% data.
+
+    Overwrites the database weight_used for every assigned spool using the
+    current AMS remain% from connected printers.  This is a manual recovery
+    tool — it bypasses the normal "only increase" guard.
+    """
+    from backend.app.services.printer_manager import printer_manager
+
+    result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
+    assignments = list(result.scalars().all())
+    logger.info("AMS weight sync: found %d assignments", len(assignments))
+
+    synced = 0
+    skipped = 0
+
+    for assignment in assignments:
+        spool = assignment.spool
+        if not spool:
+            logger.debug("AMS weight sync: assignment %d has no spool", assignment.id)
+            skipped += 1
+            continue
+
+        state = printer_manager.get_status(assignment.printer_id)
+        if not state or not state.raw_data:
+            logger.info(
+                "AMS weight sync: printer %d not connected, skipping spool %d",
+                assignment.printer_id,
+                spool.id,
+            )
+            skipped += 1
+            continue
+
+        ams_raw = state.raw_data.get("ams", [])
+        if isinstance(ams_raw, dict):
+            ams_raw = ams_raw.get("ams", [])
+        tray = _find_tray_in_ams_data(ams_raw, assignment.ams_id, assignment.tray_id)
+        if not tray:
+            logger.info(
+                "AMS weight sync: no tray data for spool %d (printer %d AMS%d-T%d)",
+                spool.id,
+                assignment.printer_id,
+                assignment.ams_id,
+                assignment.tray_id,
+            )
+            skipped += 1
+            continue
+
+        remain_raw = tray.get("remain")
+        if remain_raw is None:
+            logger.debug("AMS weight sync: no remain value for spool %d", spool.id)
+            skipped += 1
+            continue
+
+        try:
+            remain_val = int(remain_raw)
+        except (TypeError, ValueError):
+            skipped += 1
+            continue
+
+        if remain_val < 0 or remain_val > 100:
+            logger.debug("AMS weight sync: invalid remain=%s for spool %d", remain_raw, spool.id)
+            skipped += 1
+            continue
+
+        lw = spool.label_weight or 1000
+        new_used = round(lw * (100 - remain_val) / 100.0, 1)
+        old_used = spool.weight_used or 0
+
+        if round(old_used, 1) != new_used:
+            logger.info(
+                "AMS weight sync: spool %d weight_used %s -> %s (remain=%d%%)",
+                spool.id,
+                old_used,
+                new_used,
+                remain_val,
+            )
+            spool.weight_used = new_used
+            synced += 1
+        else:
+            skipped += 1
+
+    await db.commit()
+    return {"synced": synced, "skipped": skipped}
+
+
 # ── Helpers ──────────────────────────────────────────────────────────────────
 
 

+ 1 - 1
backend/app/main.py

@@ -689,7 +689,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                     remain_val = int(remain_raw)
                                 except (TypeError, ValueError):
                                     remain_val = -1
-                                if 0 <= remain_val <= 100:
+                                if 1 <= remain_val <= 100:
                                     lw = existing_assignment.spool.label_weight or 1000
                                     new_used = round(lw * (100 - remain_val) / 100.0, 1)
                                     current_used = existing_assignment.spool.weight_used or 0

+ 242 - 0
backend/tests/unit/test_sync_ams_weights.py

@@ -0,0 +1,242 @@
+"""Unit tests for the AMS weight sync calculation logic.
+
+Tests the weight_used calculation and remain% validation extracted from
+the POST /inventory/sync-ams-weights endpoint, without requiring a database.
+"""
+
+import pytest
+
+from backend.app.api.routes.inventory import _find_tray_in_ams_data
+
+
+def _calc_weight_used(label_weight: int | None, remain: int) -> float:
+    """Reproduce the weight calculation from sync_weights_from_ams."""
+    lw = label_weight or 1000
+    return round(lw * (100 - remain) / 100.0, 1)
+
+
+def _is_valid_remain(remain_raw) -> tuple[bool, int]:
+    """Reproduce the remain% validation from sync_weights_from_ams.
+
+    Returns (is_valid, parsed_value).  parsed_value is only meaningful
+    when is_valid is True.
+    """
+    if remain_raw is None:
+        return False, 0
+    try:
+        val = int(remain_raw)
+    except (TypeError, ValueError):
+        return False, 0
+    if val < 0 or val > 100:
+        return False, val
+    return True, val
+
+
+class TestWeightCalculation:
+    """Test the weight_used = label_weight * (100 - remain) / 100 formula."""
+
+    def test_remain_100_means_no_usage(self):
+        """A full spool (remain=100) should have weight_used=0."""
+        assert _calc_weight_used(1000, 100) == 0.0
+
+    def test_remain_50_with_1000g_spool(self):
+        """Half-used 1000g spool should have weight_used=500."""
+        assert _calc_weight_used(1000, 50) == 500.0
+
+    def test_remain_0_means_fully_used(self):
+        """An empty spool (remain=0) should have weight_used equal to label_weight.
+
+        Unlike the on_ams_change guard, the sync endpoint processes remain=0
+        since it is a manual recovery tool.
+        """
+        assert _calc_weight_used(1000, 0) == 1000.0
+
+    def test_respects_label_weight_500g(self):
+        """500g spool at remain=50 should have weight_used=250."""
+        assert _calc_weight_used(500, 50) == 250.0
+
+    def test_respects_label_weight_250g(self):
+        """250g spool at remain=75 should have weight_used=62.5."""
+        assert _calc_weight_used(250, 75) == 62.5
+
+    def test_none_label_weight_defaults_to_1000(self):
+        """When label_weight is None, it defaults to 1000g."""
+        assert _calc_weight_used(None, 50) == 500.0
+
+    def test_result_is_rounded_to_one_decimal(self):
+        """Weight used should be rounded to 1 decimal place.
+
+        For a 1000g spool at remain=33, weight_used = 1000 * 67 / 100 = 670.0
+        """
+        assert _calc_weight_used(1000, 33) == 670.0
+
+    def test_odd_fraction_rounds_correctly(self):
+        """750g spool at remain=33 → 750 * 67/100 = 502.5."""
+        assert _calc_weight_used(750, 33) == 502.5
+
+    def test_small_spool_small_remain(self):
+        """200g spool at remain=1 → 200 * 99/100 = 198.0."""
+        assert _calc_weight_used(200, 1) == 198.0
+
+
+class TestRemainValidation:
+    """Test the remain% bounds and type validation."""
+
+    def test_remain_minus_1_is_invalid(self):
+        """remain=-1 (firmware 'unknown') should be skipped."""
+        valid, _ = _is_valid_remain(-1)
+        assert valid is False
+
+    def test_remain_101_is_invalid(self):
+        """remain=101 (out of range) should be skipped."""
+        valid, _ = _is_valid_remain(101)
+        assert valid is False
+
+    def test_remain_negative_large_is_invalid(self):
+        """Large negative remain values should be skipped."""
+        valid, _ = _is_valid_remain(-50)
+        assert valid is False
+
+    def test_remain_200_is_invalid(self):
+        """remain=200 should be skipped."""
+        valid, _ = _is_valid_remain(200)
+        assert valid is False
+
+    def test_remain_none_is_invalid(self):
+        """remain=None (missing from tray data) should be skipped."""
+        valid, _ = _is_valid_remain(None)
+        assert valid is False
+
+    def test_remain_non_numeric_string_is_invalid(self):
+        """Non-numeric string remain should be skipped."""
+        valid, _ = _is_valid_remain("abc")
+        assert valid is False
+
+    def test_remain_0_is_valid(self):
+        """remain=0 should be valid (manual recovery handles empty spools)."""
+        valid, val = _is_valid_remain(0)
+        assert valid is True
+        assert val == 0
+
+    def test_remain_100_is_valid(self):
+        """remain=100 should be valid."""
+        valid, val = _is_valid_remain(100)
+        assert valid is True
+        assert val == 100
+
+    def test_remain_50_is_valid(self):
+        """remain=50 should be valid."""
+        valid, val = _is_valid_remain(50)
+        assert valid is True
+        assert val == 50
+
+    def test_remain_string_number_is_valid(self):
+        """Numeric string remain (e.g. '75') should be parsed as int."""
+        valid, val = _is_valid_remain("75")
+        assert valid is True
+        assert val == 75
+
+
+class TestFindTrayInAmsData:
+    """Test the _find_tray_in_ams_data helper used by the sync endpoint."""
+
+    def test_finds_matching_tray(self):
+        """Should return the matching tray dict."""
+        ams_data = [
+            {
+                "id": 0,
+                "tray": [
+                    {"id": 0, "remain": 80},
+                    {"id": 1, "remain": 50},
+                ],
+            },
+        ]
+        tray = _find_tray_in_ams_data(ams_data, ams_id=0, tray_id=1)
+        assert tray is not None
+        assert tray["remain"] == 50
+
+    def test_returns_none_for_missing_ams_unit(self):
+        """Should return None when the AMS unit ID is not found."""
+        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
+        assert _find_tray_in_ams_data(ams_data, ams_id=1, tray_id=0) is None
+
+    def test_returns_none_for_missing_tray(self):
+        """Should return None when the tray ID is not found."""
+        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
+        assert _find_tray_in_ams_data(ams_data, ams_id=0, tray_id=3) is None
+
+    def test_returns_none_for_empty_data(self):
+        """Should return None for empty AMS data."""
+        assert _find_tray_in_ams_data([], ams_id=0, tray_id=0) is None
+
+    def test_returns_none_for_none_data(self):
+        """Should return None for None AMS data."""
+        assert _find_tray_in_ams_data(None, ams_id=0, tray_id=0) is None
+
+    def test_multi_ams_unit_lookup(self):
+        """Should find trays across multiple AMS units."""
+        ams_data = [
+            {"id": 0, "tray": [{"id": 0, "remain": 80}]},
+            {"id": 1, "tray": [{"id": 2, "remain": 30}]},
+        ]
+        tray = _find_tray_in_ams_data(ams_data, ams_id=1, tray_id=2)
+        assert tray is not None
+        assert tray["remain"] == 30
+
+    def test_ams_ht_high_id(self):
+        """Should find trays in AMS-HT units (id >= 128)."""
+        ams_data = [{"id": 128, "tray": [{"id": 0, "remain": 65}]}]
+        tray = _find_tray_in_ams_data(ams_data, ams_id=128, tray_id=0)
+        assert tray is not None
+        assert tray["remain"] == 65
+
+
+class TestSyncSkipLogic:
+    """Test combinations that exercise the sync/skip decision path."""
+
+    def test_same_value_is_skipped(self):
+        """When old weight_used matches new, the spool is skipped (no DB write)."""
+        # Simulating the endpoint logic: if round(old_used, 1) == new_used → skip
+        label_weight = 1000
+        remain = 50
+        new_used = _calc_weight_used(label_weight, remain)
+        old_used = 500.0  # Already matches
+        assert round(old_used, 1) == new_used  # → would be skipped
+
+    def test_different_value_is_synced(self):
+        """When old weight_used differs from new, the spool is synced."""
+        label_weight = 1000
+        remain = 50
+        new_used = _calc_weight_used(label_weight, remain)
+        old_used = 300.0  # Different
+        assert round(old_used, 1) != new_used  # → would be synced
+
+    def test_none_old_used_treated_as_zero(self):
+        """When old weight_used is None (new spool), it defaults to 0."""
+        old_used = None
+        effective_old = old_used or 0
+        new_used = _calc_weight_used(1000, 80)  # 200.0
+        assert effective_old == 0
+        assert round(effective_old, 1) != new_used  # → would be synced
+
+    def test_remain_0_synced_not_skipped(self):
+        """remain=0 is valid and produces weight_used=label_weight.
+
+        This is distinct from on_ams_change behavior where remain=0 is
+        ignored.  The sync endpoint processes it as a manual recovery tool.
+        """
+        valid, val = _is_valid_remain(0)
+        assert valid is True
+        new_used = _calc_weight_used(1000, val)
+        assert new_used == 1000.0
+
+    def test_remain_minus_1_never_reaches_calc(self):
+        """remain=-1 fails validation before weight calculation."""
+        valid, _ = _is_valid_remain(-1)
+        assert valid is False
+        # The endpoint would skip += 1 and continue
+
+    def test_remain_101_never_reaches_calc(self):
+        """remain=101 fails validation before weight calculation."""
+        valid, _ = _is_valid_remain(101)
+        assert valid is False

+ 186 - 0
frontend/src/__tests__/components/SpoolFormModal.test.tsx

@@ -0,0 +1,186 @@
+/**
+ * Tests for the SpoolFormModal weightTouched behavior.
+ *
+ * Verifies that weight_used is only included in the PATCH payload when the user
+ * explicitly changes the remaining weight field. This prevents stale React Query
+ * cache values from overwriting usage-tracked weight data on the backend.
+ */
+
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import { SpoolFormModal } from '../../components/SpoolFormModal';
+import type { InventorySpool } from '../../api/client';
+
+// Mock the API client
+vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+    getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
+    getFilamentPresets: vi.fn().mockResolvedValue([]),
+    getSpoolCatalog: vi.fn().mockResolvedValue([]),
+    getColorCatalog: vi.fn().mockResolvedValue([]),
+    getLocalPresets: vi.fn().mockResolvedValue({ filament: [] }),
+    getPrinters: vi.fn().mockResolvedValue([]),
+    getSpoolUsageHistory: vi.fn().mockResolvedValue([]),
+    createSpool: vi.fn().mockResolvedValue({ id: 99 }),
+    updateSpool: vi.fn().mockResolvedValue({ id: 1 }),
+    saveSpoolKProfiles: vi.fn().mockResolvedValue([]),
+  },
+}));
+
+// Mock validateForm so we can bypass validation for the create-mode test
+// (editing tests pass validation naturally since the spool has material + slicer_filament)
+vi.mock('../../components/spool-form/types', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../components/spool-form/types')>();
+  return {
+    ...actual,
+    validateForm: vi.fn().mockReturnValue({ isValid: true, errors: {} }),
+  };
+});
+
+// Mock the toast context
+const mockShowToast = vi.fn();
+vi.mock('../../contexts/ToastContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
+  return {
+    ...actual,
+    useToast: () => ({ showToast: mockShowToast }),
+  };
+});
+
+import { api } from '../../api/client';
+
+const existingSpool: InventorySpool = {
+  id: 1,
+  material: 'PLA',
+  subtype: 'Basic',
+  brand: 'Polymaker',
+  color_name: 'Red',
+  rgba: 'FF0000FF',
+  label_weight: 1000,
+  core_weight: 250,
+  weight_used: 300,
+  slicer_filament: 'GFL99',
+  slicer_filament_name: 'Generic PLA',
+  nozzle_temp_min: null,
+  nozzle_temp_max: null,
+  note: null,
+  added_full: null,
+  last_used: null,
+  encode_time: null,
+  tag_uid: null,
+  tray_uuid: null,
+  data_origin: null,
+  tag_type: null,
+  archived_at: null,
+  created_at: '2025-01-01T00:00:00Z',
+  updated_at: '2025-01-01T00:00:00Z',
+  k_profiles: [],
+};
+
+describe('SpoolFormModal weightTouched', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('excludes weight_used from PATCH when editing without changing weight', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={existingSpool}
+      />
+    );
+
+    // Wait for the modal to render with the edit title
+    await waitFor(() => {
+      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+    });
+
+    // Click Save without touching the weight field
+    const saveButton = screen.getByRole('button', { name: /save/i });
+    fireEvent.click(saveButton);
+
+    await waitFor(() => {
+      expect(api.updateSpool).toHaveBeenCalledTimes(1);
+    });
+
+    const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
+    expect(spoolId).toBe(1);
+    // weight_used must NOT be present in the payload
+    expect(payload).not.toHaveProperty('weight_used');
+    // Other fields should still be present
+    expect(payload).toHaveProperty('material', 'PLA');
+    expect(payload).toHaveProperty('label_weight', 1000);
+  });
+
+  it('includes weight_used in PATCH when editing and changing remaining weight', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={existingSpool}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+    });
+
+    // The remaining weight is (label_weight - weight_used) = 1000 - 300 = 700.
+    // The input is a number input displaying 700. Find it by its displayed value.
+    const remainingInput = screen.getByDisplayValue('700');
+    expect(remainingInput).toBeInTheDocument();
+
+    // Change the remaining weight from 700 to 500 (weight_used becomes 1000 - 500 = 500)
+    fireEvent.change(remainingInput, { target: { value: '500' } });
+
+    // Click Save
+    const saveButton = screen.getByRole('button', { name: /save/i });
+    fireEvent.click(saveButton);
+
+    await waitFor(() => {
+      expect(api.updateSpool).toHaveBeenCalledTimes(1);
+    });
+
+    const [spoolId, payload] = vi.mocked(api.updateSpool).mock.calls[0];
+    expect(spoolId).toBe(1);
+    // weight_used MUST be present since the user changed the weight
+    expect(payload).toHaveProperty('weight_used', 500);
+  });
+
+  it('includes weight_used when creating a new spool', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+      />
+    );
+
+    // Wait for the modal to render with the create title
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
+    });
+
+    // Click the submit button (validation is mocked to always pass).
+    // The default form data has weight_used=0, and for create mode the condition
+    //   if (!isEditing || weightTouched) { data.weight_used = formData.weight_used; }
+    // always includes weight_used since isEditing is false.
+    // The submit button also says "Add Spool" — use getAllByText and pick the button.
+    const addButtons = screen.getAllByRole('button', { name: /add spool/i });
+    const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save'));
+    expect(submitButton).toBeTruthy();
+    fireEvent.click(submitButton!);
+
+    await waitFor(() => {
+      expect(api.createSpool).toHaveBeenCalledTimes(1);
+    });
+
+    const [payload] = vi.mocked(api.createSpool).mock.calls[0];
+    // weight_used MUST be included for new spools (default value 0)
+    expect(payload).toHaveProperty('weight_used', 0);
+  });
+});

+ 2 - 0
frontend/src/api/client.ts

@@ -3497,6 +3497,8 @@ export const api = {
     request<SpoolUsageRecord[]>(`/inventory/usage?limit=${limit}${printerId ? `&printer_id=${printerId}` : ''}`),
   clearSpoolUsageHistory: (spoolId: number) =>
     request<{ status: string }>(`/inventory/spools/${spoolId}/usage`, { method: 'DELETE' }),
+  syncWeightsFromAms: () =>
+    request<{ synced: number; skipped: number }>('/inventory/sync-ams-weights', { method: 'POST' }),
   getFilamentPresets: () =>
     request<SlicerSetting[]>('/cloud/filaments'),
 

+ 56 - 10
frontend/src/components/SpoolmanSettings.tsx

@@ -6,6 +6,7 @@ import { api } from '../api/client';
 import type { SpoolmanSyncResult, Printer } from '../api/client';
 import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 
 export function SpoolmanSettings() {
@@ -20,6 +21,7 @@ export function SpoolmanSettings() {
   const [selectedPrinterId, setSelectedPrinterId] = useState<number | 'all'>('all');
   const [isInitialized, setIsInitialized] = useState(false);
   const [showAllSkipped, setShowAllSkipped] = useState(false);
+  const [showAmsSyncConfirm, setShowAmsSyncConfirm] = useState(false);
 
   // Fetch Spoolman settings
   const { data: settings, isLoading: settingsLoading } = useQuery({
@@ -136,6 +138,21 @@ export function SpoolmanSettings() {
     }
   };
 
+  // Inventory AMS weight sync mutation
+  const amsSyncMutation = useMutation({
+    mutationFn: api.syncWeightsFromAms,
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ['spools'] });
+      queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
+      showToast(t('settings.amsSyncSuccess', { synced: data.synced, skipped: data.skipped }), 'success');
+      setShowAmsSyncConfirm(false);
+    },
+    onError: () => {
+      showToast(t('settings.amsSyncError'), 'error');
+      setShowAmsSyncConfirm(false);
+    },
+  });
+
   // Combine mutation states
   const isSyncing = syncAllMutation.isPending || syncPrinterMutation.isPending;
   const syncResult = selectedPrinterId === 'all' ? syncAllMutation.data : syncPrinterMutation.data;
@@ -236,18 +253,34 @@ export function SpoolmanSettings() {
 
         {/* Built-in Inventory details */}
         {!localEnabled && (
-          <div className="p-3 bg-bambu-green/5 border border-bambu-green/20 rounded-lg">
-            <div className="flex gap-2">
-              <Info className="w-4 h-4 text-bambu-green flex-shrink-0 mt-0.5" />
-              <div className="text-xs text-bambu-gray">
-                <ul className="list-disc list-inside space-y-0.5">
-                  <li>{t('settings.builtInFeatureRfid')}</li>
-                  <li>{t('settings.builtInFeatureUsage')}</li>
-                  <li>{t('settings.builtInFeatureCatalog')}</li>
-                  <li>{t('settings.builtInFeatureThirdParty')}</li>
-                </ul>
+          <div className="space-y-3">
+            <div className="p-3 bg-bambu-green/5 border border-bambu-green/20 rounded-lg">
+              <div className="flex gap-2">
+                <Info className="w-4 h-4 text-bambu-green flex-shrink-0 mt-0.5" />
+                <div className="text-xs text-bambu-gray">
+                  <ul className="list-disc list-inside space-y-0.5">
+                    <li>{t('settings.builtInFeatureRfid')}</li>
+                    <li>{t('settings.builtInFeatureUsage')}</li>
+                    <li>{t('settings.builtInFeatureCatalog')}</li>
+                    <li>{t('settings.builtInFeatureThirdParty')}</li>
+                  </ul>
+                </div>
               </div>
             </div>
+
+            <Button
+              variant="secondary"
+              size="sm"
+              onClick={() => setShowAmsSyncConfirm(true)}
+              disabled={amsSyncMutation.isPending}
+            >
+              {amsSyncMutation.isPending ? (
+                <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+              ) : (
+                <RefreshCw className="w-4 h-4 mr-2" />
+              )}
+              {t('settings.amsSyncButton')}
+            </Button>
           </div>
         )}
 
@@ -528,6 +561,19 @@ export function SpoolmanSettings() {
           </div>
         )}
       </CardContent>
+
+      {showAmsSyncConfirm && (
+        <ConfirmModal
+          title={t('settings.amsSyncTitle')}
+          message={t('settings.amsSyncMessage')}
+          confirmText={t('settings.amsSyncButton')}
+          variant="warning"
+          isLoading={amsSyncMutation.isPending}
+          loadingText={t('settings.amsSyncing')}
+          onConfirm={() => amsSyncMutation.mutate()}
+          onCancel={() => setShowAmsSyncConfirm(false)}
+        />
+      )}
     </Card>
   );
 }

+ 6 - 0
frontend/src/i18n/locales/de.ts

@@ -1203,6 +1203,12 @@ export default {
     builtInFeatureUsage: 'Erfasst den Filamentverbrauch pro Druck',
     builtInFeatureCatalog: 'Spulen, Farben und K-Faktor-Profile verwalten',
     builtInFeatureThirdParty: 'Drittanbieter-Spulen können Inventarspulen zugewiesen werden',
+    amsSyncButton: 'Gewichte vom AMS synchronisieren',
+    amsSyncTitle: 'Spulengewichte vom AMS synchronisieren',
+    amsSyncMessage: 'Alle Inventar-Spulengewichte werden mit den aktuellen AMS-Restwerten der verbundenen Drucker überschrieben. Verwenden Sie dies zur Wiederherstellung beschädigter Gewichtsdaten. Drucker müssen online sein.',
+    amsSyncing: 'Synchronisiere...',
+    amsSyncSuccess: '{{synced}} Spule(n) synchronisiert, {{skipped}} übersprungen',
+    amsSyncError: 'Synchronisierung der Gewichte vom AMS fehlgeschlagen',
     // Spoolman settings
     spoolmanUrl: 'Spoolman URL',
     spoolmanUrlHint: 'URL Ihres Spoolman-Servers (z.B. http://localhost:7912)',

+ 6 - 0
frontend/src/i18n/locales/en.ts

@@ -1203,6 +1203,12 @@ export default {
     builtInFeatureUsage: 'Tracks filament consumption per print',
     builtInFeatureCatalog: 'Manage spools, colors, and K-factor profiles',
     builtInFeatureThirdParty: 'Third-party spools can be assigned to inventory spools',
+    amsSyncButton: 'Sync Weights from AMS',
+    amsSyncTitle: 'Sync Spool Weights from AMS',
+    amsSyncMessage: 'This will overwrite all inventory spool weights with the current AMS remain% values from connected printers. Use this to recover from corrupted weight data. Printers must be online.',
+    amsSyncing: 'Syncing...',
+    amsSyncSuccess: '{{synced}} spool(s) synced, {{skipped}} skipped',
+    amsSyncError: 'Failed to sync weights from AMS',
     // Spoolman settings
     spoolmanUrl: 'Spoolman URL',
     spoolmanUrlHint: 'URL of your Spoolman server (e.g., http://localhost:7912)',

+ 6 - 0
frontend/src/i18n/locales/fr.ts

@@ -1199,6 +1199,12 @@ export default {
     builtInFeatureUsage: 'Suit la consommation par impression',
     builtInFeatureCatalog: 'Gère bobines, couleurs et profils facteur K',
     builtInFeatureThirdParty: 'Les bobines tierces peuvent être assignées aux bobines d\'inventaire',
+    amsSyncButton: 'Synchroniser les poids depuis l\'AMS',
+    amsSyncTitle: 'Synchroniser les poids des bobines depuis l\'AMS',
+    amsSyncMessage: 'Tous les poids des bobines de l\'inventaire seront écrasés par les valeurs actuelles de l\'AMS des imprimantes connectées. Utilisez ceci pour récupérer des données de poids corrompues. Les imprimantes doivent être en ligne.',
+    amsSyncing: 'Synchronisation...',
+    amsSyncSuccess: '{{synced}} bobine(s) synchronisée(s), {{skipped}} ignorée(s)',
+    amsSyncError: 'Échec de la synchronisation des poids depuis l\'AMS',
     // Spoolman settings
     spoolmanUrl: 'URL Spoolman',
     spoolmanUrlHint: 'URL de votre serveur Spoolman (ex: http://localhost:7912)',

+ 6 - 0
frontend/src/i18n/locales/ja.ts

@@ -1451,6 +1451,12 @@ export default {
     builtInFeatureUsage: 'プリントごとのフィラメント消費量を追跡',
     builtInFeatureCatalog: 'スプール、カラー、K値プロファイルを管理',
     builtInFeatureThirdParty: 'サードパーティ製スプールをインベントリスプールに割り当て可能',
+    amsSyncButton: 'AMSから重量を同期',
+    amsSyncTitle: 'AMSからスプール重量を同期',
+    amsSyncMessage: '接続されたプリンターの現在のAMS残量値で、すべてのインベントリスプール重量を上書きします。破損した重量データの復旧に使用してください。プリンターがオンラインである必要があります。',
+    amsSyncing: '同期中...',
+    amsSyncSuccess: '{{synced}}個のスプールを同期、{{skipped}}個をスキップ',
+    amsSyncError: 'AMSからの重量同期に失敗しました',
     // Spoolman設定
     spoolmanUrl: 'Spoolman URL',
     spoolmanUrlHint: 'Spoolmanサーバーのurl(例:http://localhost:7912)',

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-LH77R5cV.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DDGLa0P-.js"></script>
+    <script type="module" crossorigin src="/assets/index-LH77R5cV.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-VlqasY_r.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов