Procházet zdrojové kódy

Show the spool that is in the AMS slot, not the one that was

    Pull a Bambu ABS Orange out of A1, put a PLA Matte Dark Blue in, and the
    slot card still read "Bambu ABS" against the new colour until the page
    was reloaded.

    Three things stood between the swap and a correct card.

    The RFID auto-assign rewrites the slot's slot_preset_mappings row and
    then broadcast an event that refreshed everything except the query that
    reads it. Only the manual assign path invalidated that one.

    Those queries then sat behind the 3s cascade debounce, which exists for
    print completion, where one event fans out across half the app. A swap
    touches one slot and the user is standing at the printer looking at the
    card; worse, the timer restarts on every further event, so a busy moment
    could defer it indefinitely. Slot changes now invalidate immediately.

    And the card trusted the stored preset over live telemetry outright.
    That priority is why a hand-picked preset name stays on a slot, but it
    also let a cached row outrank what the printer was reporting. The row is
    now skipped when it names a different official Bambu filament than the
    tray does, so the card is right from the status push alone. User and
    local presets carry ids that genuinely cannot be compared and are left
    exactly as they were.

    Spoolman mode was the worse half of the same bug: its AMS sync writes
    the same row but announced nothing at all, so there was no event to
    refresh on. It now reports each slot it changed or cleared.
maziggy před 1 týdnem
rodič
revize
f616a6bca9

+ 20 - 0
backend/app/main.py

@@ -2774,6 +2774,26 @@ async def on_ams_change(printer_id: int, ams_data: list):
                 except Exception as e:
                     await db.rollback()
                     logger.error("Error persisting Spoolman slot assignments for printer %s: %s", printer_id, e)
+                else:
+                    # Tell open browsers the slot changed. This loop rewrites
+                    # slot_preset_mappings via upsert_slot_preset_for_spoolman_spool
+                    # above, and the AMS slot card reads that row ahead of the
+                    # live tray_info_idx -- so with no event the card keeps
+                    # showing the previous spool's preset name. Internal mode
+                    # raises spool_auto_assigned for the same reason; this loop
+                    # broadcast nothing at all, which made Spoolman mode the
+                    # worse half of the same bug. On the else branch so a
+                    # failed commit stays silent and a broadcast failure cannot
+                    # roll back rows that are already committed.
+                    for ams_id, tray_id, *_ in (*slot_changes, *empty_slots):
+                        await ws_manager.broadcast(
+                            {
+                                "type": "spool_assignment_changed",
+                                "printer_id": printer_id,
+                                "ams_id": ams_id,
+                                "tray_id": tray_id,
+                            }
+                        )
 
     except Exception as e:
         logging.getLogger(__name__).error("Spoolman AMS sync failed for printer %s: %s", printer_id, e)

+ 163 - 0
backend/tests/integration/test_spoolman_ams_sync_broadcast.py

@@ -0,0 +1,163 @@
+"""Spoolman's AMS sync has to tell the browser the slot changed.
+
+Swap a spool and the AMS slot card keeps showing the *previous* spool's preset
+name. The card reads ``slot_preset_mappings.preset_name`` ahead of the live
+``tray_info_idx``, so a cached row wins over correct data pushed over the
+socket — and everything else on the card rides the status push, which is why it
+surfaces as one wrong line rather than an obviously stale card.
+
+Built-in inventory raised ``spool_auto_assigned`` for this (the frontend simply
+forgot to invalidate ``slotPresets`` on it). This loop raised nothing at all,
+even though it rewrites the very same row through
+``upsert_slot_preset_for_spoolman_spool`` — so in Spoolman mode there was no
+event to hang an invalidation on, and the stale name stood until an unrelated
+refetch.
+
+An emptied slot counts: its ``spoolman_slot_assignments`` row is deleted here,
+and a card still drawing the removed spool is the same defect.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.settings import Settings
+from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
+
+
+def _status(ams_data):
+    status = MagicMock()
+    status.raw_data = {"ams": ams_data, "vt_tray": []}
+    status.gcode_state = "IDLE"
+    return status
+
+
+def _tray(ams_id: int, tray_id: int):
+    """What ``client.parse_ams_tray`` hands back for an occupied slot."""
+    tray = MagicMock()
+    tray.ams_id = ams_id
+    tray.tray_id = tray_id
+    tray.tray_uuid = "EB335968299543078925C71D83DA3864"
+    tray.tag_uid = "7757EF0100000100"
+    tray.tray_info_idx = "GFA01"
+    tray.tray_type = "PLA"
+    tray.tray_sub_brands = "PLA Matte"
+    tray.tray_color = "042F56FF"
+    return tray
+
+
+async def _run_ams_change(printer_id: int, ams_data: list, *, parsed):
+    """Drive ``on_ams_change`` with Spoolman standing in for the real server.
+
+    ``parsed`` maps (ams_id, tray_id) to a parsed tray or ``None`` (empty slot),
+    which is the only thing that decides whether the sync treats the slot as
+    occupied or cleared.
+    """
+    from backend.app.main import on_ams_change
+
+    spoolman = MagicMock()
+    spoolman.health_check = AsyncMock(return_value=True)
+    spoolman.get_spools = AsyncMock(return_value=[])
+    spoolman.parse_ams_tray = MagicMock(
+        side_effect=lambda ams_id, tray_data: parsed.get((ams_id, int(tray_data.get("id", 0))))
+    )
+    spoolman.sync_ams_tray = AsyncMock(return_value={"id": 4242})
+
+    status = _status(ams_data)
+    with (
+        patch("backend.app.main.printer_manager") as pm_main,
+        patch("backend.app.services.printer_manager.printer_manager") as pm_inv,
+        patch("backend.app.main.mqtt_relay") as relay,
+        patch("backend.app.main.ws_manager") as ws,
+        patch("backend.app.main.get_spoolman_client", AsyncMock(return_value=spoolman)),
+        patch(
+            "backend.app.services.slot_preset_writer.upsert_slot_preset_for_spoolman_spool",
+            AsyncMock(),
+        ),
+    ):
+        pm_main.get_printer.return_value = MagicMock(name="P", serial_number="SER")
+        pm_main.get_status.return_value = status
+        pm_main.get_client.return_value = MagicMock()
+        pm_main.get_model.return_value = "X1C"
+        pm_inv.get_status.return_value = status
+        pm_inv.get_client.return_value = MagicMock()
+        relay.on_ams_change = AsyncMock()
+        ws.send_printer_status = AsyncMock()
+        ws.broadcast = AsyncMock()
+        await on_ams_change(printer_id, ams_data)
+        return ws.broadcast, spoolman
+
+
+def _slot_events(broadcast) -> set[tuple[int, int]]:
+    """(ams_id, tray_id) of every assignment-changed event that went out."""
+    return {
+        (call.args[0]["ams_id"], call.args[0]["tray_id"])
+        for call in broadcast.call_args_list
+        if call.args and call.args[0].get("type") == "spool_assignment_changed"
+    }
+
+
+async def _enable_spoolman(db: AsyncSession) -> None:
+    db.add(Settings(key="spoolman_enabled", value="true"))
+    db.add(Settings(key="spoolman_url", value="http://spoolman.invalid:7912"))
+    await db.commit()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_synced_slot_is_broadcast(async_client: AsyncClient, printer_factory, db_session: AsyncSession):
+    printer = await printer_factory(name="H2C")
+    await _enable_spoolman(db_session)
+
+    broadcast, spoolman = await _run_ams_change(
+        printer.id,
+        [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "042F56FF", "state": 11}]}],
+        parsed={(0, 0): _tray(0, 0)},
+    )
+
+    spoolman.sync_ams_tray.assert_awaited()
+    assert (0, 0) in _slot_events(broadcast)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_an_emptied_slot_is_broadcast(async_client: AsyncClient, printer_factory, db_session: AsyncSession):
+    """The row is deleted here; a card still drawing the removed spool is the
+    same bug seen from the other side."""
+    printer = await printer_factory(name="H2C")
+    await _enable_spoolman(db_session)
+    db_session.add(SpoolmanSlotAssignment(printer_id=printer.id, ams_id=0, tray_id=1, spoolman_spool_id=7))
+    await db_session.commit()
+
+    broadcast, _ = await _run_ams_change(
+        printer.id,
+        [{"id": 0, "tray": [{"id": 1}]}],
+        parsed={(0, 1): None},
+    )
+
+    assert (0, 1) in _slot_events(broadcast)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_nothing_is_broadcast_when_no_slot_changed(
+    async_client: AsyncClient, printer_factory, db_session: AsyncSession
+):
+    """A push that changes no slot must stay quiet — this runs on every AMS
+    message, and an event per push would invalidate the browser's caches
+    continuously."""
+    printer = await printer_factory(name="H2C")
+    await _enable_spoolman(db_session)
+
+    broadcast, spoolman = await _run_ams_change(
+        printer.id,
+        [{"id": 0, "tray": []}],
+        parsed={},
+    )
+
+    # Guards the assertion below against passing because the sync bailed out
+    # early on a mis-set fixture rather than because it found nothing to say.
+    spoolman.get_spools.assert_awaited()
+    assert _slot_events(broadcast) == set()

+ 96 - 0
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -470,6 +470,102 @@ describe('useWebSocket hook', () => {
       vi.unstubAllGlobals();
     });
 
+    /*
+     * Swapping a spool leaves the previous spool's preset name on the AMS slot
+     * card.
+     *
+     * The RFID auto-assign rewrites the slot's slot_preset_mappings row, and
+     * PrintersPage reads `slotPreset?.preset_name` *ahead of* the live
+     * tray_info_idx lookup -- so a cached row wins over correct data pushed
+     * over the socket. Everything else on the card rides the status push and
+     * updates instantly, which is why this surfaces as one wrong line rather
+     * than a stale card: pull a Bambu ABS Orange, insert a PLA Matte Dark
+     * Blue, and the card reads "Bambu ABS" against the new colour.
+     *
+     * `slotPresets` has a 2-minute staleTime and no refetch interval, so on a
+     * dashboard left open and focused nothing ever refetches it.
+     */
+    it('invalidates slot presets on spool_auto_assigned message', async () => {
+      vi.useFakeTimers();
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+
+      const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
+
+      renderHook(() => useWebSocket(), {
+        wrapper: createWrapper(queryClient),
+      });
+
+      const ws = await waitForWs();
+
+      act(() => {
+        ws.open();
+      });
+
+      act(() => {
+        ws.simulateMessage({
+          type: 'spool_auto_assigned',
+          printer_id: 7,
+          ams_id: 0,
+          tray_id: 0,
+          spool_id: 110,
+        });
+      });
+
+      // No timer advance: the user is standing at the printer looking at the
+      // card, so the slot's own queries must not wait out the 3s cascade
+      // debounce (which any further event would restart anyway).
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['slotPresets'] });
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spool-assignments'] });
+
+      // The spool list is not on the card's critical path and stays debounced.
+      expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ['inventory-spools'] });
+      await act(async () => {
+        vi.advanceTimersByTime(5000);
+      });
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['inventory-spools'] });
+
+      vi.useRealTimers();
+      vi.unstubAllGlobals();
+    });
+
+    /*
+     * Spoolman mode reaches the same slot_preset_mappings row through its own
+     * AMS sync, which raises spool_assignment_changed. Its slot rows live under
+     * a different query key, so the internal-mode key alone left that half of
+     * the UI on the previous spool.
+     */
+    it('invalidates both inventory modes on spool_assignment_changed message', async () => {
+      vi.useFakeTimers();
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+
+      const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
+
+      renderHook(() => useWebSocket(), {
+        wrapper: createWrapper(queryClient),
+      });
+
+      const ws = await waitForWs();
+
+      act(() => {
+        ws.open();
+      });
+
+      act(() => {
+        ws.simulateMessage({
+          type: 'spool_assignment_changed',
+          printer_id: 7,
+          ams_id: 0,
+          tray_id: 0,
+        });
+      });
+
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['slotPresets'] });
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spool-assignments'] });
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['spoolman-slot-assignments'] });
+
+      vi.useRealTimers();
+      vi.unstubAllGlobals();
+    });
     it('handles missing_spool_assignment message without error', async () => {
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 

+ 153 - 0
frontend/src/__tests__/pages/PrintersPageSlotPresetStale.test.tsx

@@ -0,0 +1,153 @@
+/**
+ * A swapped spool must not leave the previous spool's preset name on the card.
+ *
+ * `slot_preset_mappings` is fetched over REST and remembers what the slot was
+ * last configured with; the tray's own `tray_info_idx` arrives on the
+ * WebSocket. The display chain puts the stored row first -- that is what keeps
+ * a hand-picked preset name on a slot -- so between the swap and the row being
+ * refetched the card named the spool that had just been removed. Everything
+ * else on the card rides the status push and was already correct, which is why
+ * it read as one wrong line rather than a stale card.
+ *
+ * Verbatim from the report: a Bambu ABS Orange came out of A1, a PLA Matte
+ * Dark Blue went in, and the card still said "Bambu ABS".
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockPrinter = {
+  id: 1,
+  name: 'X1 Carbon',
+  ip_address: '192.168.1.100',
+  serial_number: '00M09A350100001',
+  access_code: '12345678',
+  model: 'X1C',
+  enabled: true,
+  nozzle_diameter: 0.4,
+  nozzle_type: 'hardened_steel',
+  location: 'Workshop',
+  auto_archive: true,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+/** The PLA Matte Dark Blue now in the slot, as the printer reports it. */
+const darkBlue = {
+  tray_color: '042F56FF',
+  tray_type: 'PLA',
+  tray_sub_brands: 'PLA Matte',
+  tray_id_name: 'A01-B3',
+  tray_info_idx: 'GFA01',
+  remain: 80,
+  k: 0.02,
+  cali_idx: null,
+  tag_uid: null,
+  tray_uuid: null,
+  nozzle_temp_min: 190,
+  nozzle_temp_max: 230,
+  drying_temp: null,
+  drying_time: null,
+  state: 11,
+};
+
+const status = {
+  connected: true,
+  state: 'IDLE',
+  progress: 0,
+  layer_num: 0,
+  total_layers: 0,
+  temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+  remaining_time: 0,
+  filename: null,
+  wifi_signal: -50,
+  speed_level: 2,
+  vt_tray: [],
+  ams: [
+    {
+      id: 0,
+      humidity: 30,
+      temp: 25,
+      is_ams_ht: false,
+      serial_number: 'AMS00',
+      sw_ver: '1.0.0',
+      dry_time: 0,
+      dry_status: 0,
+      dry_sub_status: 0,
+      dry_sf_reason: [],
+      module_type: 'ams',
+      tray: [{ id: 0, ...darkBlue }],
+    },
+  ],
+};
+
+/** Hover-card visibility flips after an 80ms timeout — wait it out. */
+async function hoverFirstSlot() {
+  await waitFor(() => {
+    expect(screen.getAllByTestId('filament-slot').length).toBeGreaterThan(0);
+  });
+  fireEvent.mouseEnter(screen.getAllByTestId('filament-slot')[0]);
+}
+
+function serve(slotPresets: Record<number, unknown>) {
+  server.use(
+    http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
+    http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    http.get('/api/v1/printers/:id/status', () => HttpResponse.json(status)),
+    http.get('/api/v1/printers/:id/slot-presets', () => HttpResponse.json(slotPresets)),
+    http.post('/api/v1/cloud/filament-info', () =>
+      HttpResponse.json({
+        GFA01: { name: 'Bambu PLA Matte', k: null },
+        GFB00: { name: 'Bambu ABS', k: null },
+      }),
+    ),
+  );
+}
+
+describe('PrintersPage — a stale slot preset must not name the slot', () => {
+  beforeEach(() => {
+    server.use(http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])));
+  });
+
+  it('ignores the row left by the spool that was removed', async () => {
+    serve({
+      0: { printer_id: 1, ams_id: 0, tray_id: 0, preset_id: 'GFSB00', preset_name: 'Bambu ABS', preset_source: 'cloud' },
+    });
+
+    render(<PrintersPage />);
+    await hoverFirstSlot();
+
+    // The live filament id names the slot instead.
+    await waitFor(() => {
+      expect(screen.getByText('Bambu PLA Matte')).toBeInTheDocument();
+    });
+    expect(screen.queryByText('Bambu ABS')).not.toBeInTheDocument();
+  });
+
+  it('still shows a hand-picked name while it describes what is in the slot', async () => {
+    // The whole reason the stored row outranks the catalog: this custom name
+    // must survive, and it is stored against the same official preset id the
+    // tray reports.
+    serve({
+      0: {
+        printer_id: 1,
+        ams_id: 0,
+        tray_id: 0,
+        preset_id: 'GFSA01',
+        preset_name: '# Bambu PLA Matte @BBL X1C 0.4 nozzle (Custom)',
+        preset_source: 'cloud',
+      },
+    });
+
+    render(<PrintersPage />);
+    await hoverFirstSlot();
+
+    await waitFor(() => {
+      expect(screen.getByText('# Bambu PLA Matte @BBL X1C 0.4 nozzle (Custom)')).toBeInTheDocument();
+    });
+  });
+});

+ 70 - 0
frontend/src/__tests__/utils/slotPresetDescribesTray.test.ts

@@ -0,0 +1,70 @@
+/**
+ * A stored slot preset must not outlive the spool it describes.
+ *
+ * `slot_preset_mappings` remembers what a slot was last configured with, and
+ * the AMS slot card puts that name ahead of the filament id the printer is
+ * reporting -- which is how a hand-picked name survives on the card. It also
+ * meant that pulling a Bambu ABS Orange and inserting a PLA Matte Dark Blue
+ * left "Bambu ABS" on the card against the new colour, because the row is
+ * fetched over REST while everything else arrives on the socket.
+ *
+ * The check is deliberately narrow. Official Bambu presets differ between the
+ * two id forms by one letter and can be compared; a user preset carries two
+ * genuinely unrelated ids and a local preset has no printer-side id at all, so
+ * neither can be judged here and both keep their name.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { slotPresetDescribesTray } from '../../utils/amsHelpers';
+
+describe('slotPresetDescribesTray', () => {
+  describe('official Bambu presets, where the ids can be compared', () => {
+    it('accepts the setting_id / filament_id pair for one filament', () => {
+      expect(slotPresetDescribesTray('GFSA01', 'GFA01')).toBe(true);
+    });
+
+    it('rejects the row left behind by the previous spool', () => {
+      // The reported swap: ABS Basic out, PLA Matte in.
+      expect(slotPresetDescribesTray('GFSB00', 'GFA01')).toBe(false);
+    });
+
+    it('rejects a different filament in the same family', () => {
+      // PLA Matte row, PLA Basic roll -- same GFA prefix, still not the same
+      // filament, and the card would name the wrong one.
+      expect(slotPresetDescribesTray('GFSA01', 'GFA00')).toBe(false);
+    });
+
+    it('ignores the version suffix on either side', () => {
+      expect(slotPresetDescribesTray('GFSA01_07', 'GFA01')).toBe(true);
+      expect(slotPresetDescribesTray('GFSA01', 'GFA01_07')).toBe(true);
+    });
+
+    it('is case-insensitive, since the printer is not consistent about it', () => {
+      expect(slotPresetDescribesTray('gfsa01', 'GFA01')).toBe(true);
+    });
+  });
+
+  describe('rows that cannot be judged keep their name', () => {
+    it('a user preset, whose two ids are unrelated', () => {
+      // Verbatim from a live slot: ams_filament_setting sent
+      // setting_id=PFUSa3b8b0c664c142 and the tray reports tray_info_idx=P8a85d5a.
+      // Comparing those would blank a correctly configured slot.
+      expect(slotPresetDescribesTray('PFUSa3b8b0c664c142', 'P8a85d5a')).toBe(true);
+    });
+
+    it('a local preset, which has no printer-side id', () => {
+      expect(slotPresetDescribesTray('local_68', 'GFA00')).toBe(true);
+    });
+
+    it('a slot reporting no filament id at all', () => {
+      // Generic filament with no tag -- the case the stored row exists for.
+      expect(slotPresetDescribesTray('GFSA01', '')).toBe(true);
+      expect(slotPresetDescribesTray('GFSA01', null)).toBe(true);
+    });
+
+    it('no stored row', () => {
+      expect(slotPresetDescribesTray(null, 'GFA01')).toBe(true);
+      expect(slotPresetDescribesTray(undefined, undefined)).toBe(true);
+    });
+  });
+});

+ 27 - 6
frontend/src/hooks/useWebSocket.ts

@@ -288,6 +288,20 @@ export function useWebSocket() {
     }, 3000);
   }, [queryClient]);
 
+  // Slot changes skip the cascade debounce above. That debounce exists for
+  // print completion, where one event fans out across half the app; a spool
+  // swap touches one slot and the user is standing at the printer looking at
+  // the card. Waiting 3s of quiet and then staggering the keys 500ms apart put
+  // several seconds of visibly wrong data on screen for no benefit, and the
+  // timer restarts on every further event, so a busy moment could defer it
+  // indefinitely. React Query coalesces repeat invalidations of one key, so a
+  // Spoolman sync reporting a dozen slots at once still costs three refetches.
+  const invalidateSlotQueries = useCallback(() => {
+    queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
+    queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
+    queryClient.invalidateQueries({ queryKey: ['slotPresets'] });
+  }, [queryClient]);
+
   const handleMessage = useCallback((message: WebSocketMessage) => {
     switch (message.type) {
       case 'printer_status':
@@ -386,9 +400,10 @@ export function useWebSocket() {
         break;
 
       case 'spool_assignment_changed':
-        // Spool assigned/unassigned - refresh assignment data across all tabs
-        debouncedInvalidate('spool-assignments');
-        debouncedInvalidate('slotPresets');
+        // Spool assigned/unassigned - refresh assignment data across all tabs.
+        // Both inventory modes: the Spoolman AMS sync raises this event too, and
+        // its slot rows live under their own query keys.
+        invalidateSlotQueries();
         break;
 
       case 'spool_assignment_verified': {
@@ -417,9 +432,15 @@ export function useWebSocket() {
       }
 
       case 'spool_auto_assigned':
-        // RFID tag matched - refresh inventory and assignment data
+        // RFID tag matched - refresh inventory and assignment data.
+        // slotPresets is not optional here: auto-assigning rewrites the slot's
+        // slot_preset_mappings row, and the AMS slot card reads that row ahead
+        // of the live tray_info_idx. Leave it cached and swapping a spool keeps
+        // the *previous* spool's preset name on the card -- the rest of the
+        // card updates off the status push, so it reads as one wrong line
+        // rather than a stale card. Only the manual assign path invalidated it.
         debouncedInvalidate('inventory-spools');
-        debouncedInvalidate('spool-assignments');
+        invalidateSlotQueries();
         break;
 
       case 'spool_usage_logged':
@@ -526,7 +547,7 @@ export function useWebSocket() {
         }
         break;
     }
-  }, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate, showToast, t]);
+  }, [queryClient, debouncedInvalidate, invalidateSlotQueries, throttledPrinterStatusUpdate, showToast, t]);
 
   // Keep the ref updated with latest handleMessage
   useEffect(() => {

+ 22 - 4
frontend/src/pages/PrintersPage.tsx

@@ -176,7 +176,7 @@ import { FileUploadModal } from '../components/FileUploadModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { FeedDirectionModal } from '../components/FeedDirectionModal';
-import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, installedNozzleDiameters, isBambuLabSpool, resolveSlotNozzleDiameter, resolveSlotExtruder, formatSlotLabel, FTS_INLET_SIDE } from '../utils/amsHelpers';
+import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, installedNozzleDiameters, isBambuLabSpool, resolveSlotNozzleDiameter, resolveSlotExtruder, formatSlotLabel, slotPresetDescribesTray, FTS_INLET_SIDE } from '../utils/amsHelpers';
 import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems, isPrinterCurrentlyDispatchable } from '../utils/printer';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { Collapsible } from '../components/Collapsible';
@@ -5467,6 +5467,12 @@ function PrinterCard({
                                 const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
                                 // Get saved slot preset mapping (for user-configured slots)
                                 const slotPreset = slotPresets?.[globalTrayId];
+                                // Only trusted while it still describes what the printer reports in the
+                                // slot: the row survives a spool swap, and the display chain below puts
+                                // it ahead of the live filament id (see slotPresetDescribesTray).
+                                const slotPresetName = slotPresetDescribesTray(slotPreset?.preset_id, tray?.tray_info_idx)
+                                  ? slotPreset?.preset_name
+                                  : undefined;
 
                                 // Fill level fallback chain: Spoolman → Inventory → AMS remain
                                 const trayTag = (tray?.tray_uuid || tray?.tag_uid || getFallbackSpoolTag(printer.serial_number, ams.id, slotIdx))?.toUpperCase();
@@ -5512,7 +5518,7 @@ function PrinterCard({
                                   // the hover card shows "Devil Design PLA Basic" rather than the
                                   // vendor-less form. Strip the "@<printer>..." suffix that
                                   // BambuStudio appends to user-preset names.
-                                  profile: slotPreset?.preset_name || (slotSpoolForFill ? [slotSpoolForFill.brand, slotSpoolForFill.slicer_filament_name?.split('@')[0].trim() || slotSpoolForFill.material].filter(Boolean).join(' ').trim() : null) || inventoryAssignment?.spool?.slicer_filament_name || cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
+                                  profile: slotPresetName || (slotSpoolForFill ? [slotSpoolForFill.brand, slotSpoolForFill.slicer_filament_name?.split('@')[0].trim() || slotSpoolForFill.material].filter(Boolean).join(' ').trim() : null) || inventoryAssignment?.spool?.slicer_filament_name || cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
                                   colorName: getColorName(tray.tray_color || '', tray.tray_sub_brands),
                                   colorHex: tray.tray_color || null,
                                   kFactor: formatKValue(tray.k),
@@ -5777,6 +5783,12 @@ function PrinterCard({
                       const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
                       // Get saved slot preset mapping (for user-configured slots)
                       const slotPreset = slotPresets?.[globalTrayId];
+                      // Only trusted while it still describes what the printer reports in the
+                      // slot: the row survives a spool swap, and the display chain below puts
+                      // it ahead of the live filament id (see slotPresetDescribesTray).
+                      const slotPresetName = slotPresetDescribesTray(slotPreset?.preset_id, tray?.tray_info_idx)
+                        ? slotPreset?.preset_name
+                        : undefined;
                       const htSlotId = tray?.id ?? 0;
 
                         // Fill level fallback chain: Spoolman → Inventory → AMS remain
@@ -5813,7 +5825,7 @@ function PrinterCard({
                         // Build filament data for hover card
                         const filamentData = tray?.tray_type ? {
                           vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
-                          profile: slotPreset?.preset_name || (htSlotSpoolForFill ? [htSlotSpoolForFill.brand, htSlotSpoolForFill.slicer_filament_name?.split('@')[0].trim() || htSlotSpoolForFill.material].filter(Boolean).join(' ').trim() : null) || htInventoryAssignment?.spool?.slicer_filament_name || cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
+                          profile: slotPresetName || (htSlotSpoolForFill ? [htSlotSpoolForFill.brand, htSlotSpoolForFill.slicer_filament_name?.split('@')[0].trim() || htSlotSpoolForFill.material].filter(Boolean).join(' ').trim() : null) || htInventoryAssignment?.spool?.slicer_filament_name || cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
                           colorName: getColorName(tray.tray_color || '', tray.tray_sub_brands),
                           colorHex: tray.tray_color || null,
                           kFactor: formatKValue(tray.k),
@@ -6211,6 +6223,12 @@ function PrinterCard({
                                 : '';
                               const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;
                               const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
+                              // Only trusted while it still describes what the printer reports in the
+                              // slot: the row survives a spool swap, and the display chain below puts
+                              // it ahead of the live filament id (see slotPresetDescribesTray).
+                              const extSlotPresetName = slotPresetDescribesTray(extSlotPreset?.preset_id, extTray.tray_info_idx)
+                                ? extSlotPreset?.preset_name
+                                : undefined;
 
                               const extTrayTag = (extTray.tray_uuid || extTray.tag_uid || getFallbackSpoolTag(printer.serial_number, 255, slotTrayId))?.toUpperCase();
                               const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined;
@@ -6245,7 +6263,7 @@ function PrinterCard({
 
                               const extFilamentData = {
                                 vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
-                                profile: extSlotPreset?.preset_name || (extSlotSpoolForFill ? [extSlotSpoolForFill.brand, extSlotSpoolForFill.slicer_filament_name?.split('@')[0].trim() || extSlotSpoolForFill.material].filter(Boolean).join(' ').trim() : null) || extInventoryAssignment?.spool?.slicer_filament_name || extCloudInfo?.name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
+                                profile: extSlotPresetName || (extSlotSpoolForFill ? [extSlotSpoolForFill.brand, extSlotSpoolForFill.slicer_filament_name?.split('@')[0].trim() || extSlotSpoolForFill.material].filter(Boolean).join(' ').trim() : null) || extInventoryAssignment?.spool?.slicer_filament_name || extCloudInfo?.name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
                                 colorName: getColorName(extTray.tray_color || '', extTray.tray_sub_brands),
                                 colorHex: extTray.tray_color || null,
                                 kFactor: formatKValue(extTray.k),

+ 28 - 0
frontend/src/utils/amsHelpers.ts

@@ -632,6 +632,34 @@ export function isBambuLabSpool(tray: {
   return false;
 }
 
+/**
+ * Does a stored slot preset still describe what is in the slot?
+ *
+ * `slot_preset_mappings` remembers the preset a slot was last configured with,
+ * and the AMS slot card shows that name ahead of anything the printer reports —
+ * which is what lets a slot keep a hand-picked name like "# Bambu PLA Matte
+ * @BBL H2C 0.4 nozzle (Custom)" instead of the plain catalog one. The cost is
+ * that a swapped spool leaves the previous spool's name on the card until the
+ * row is refetched, and until then a cached row outranks live telemetry.
+ *
+ * The printer's own `tray_info_idx` settles it, but only for official Bambu
+ * presets, where the two id forms differ by one letter (setting_id `GFSA01` ↔
+ * filament_id `GFA01`). A user preset genuinely carries two unrelated ids — a
+ * slot configured with `PFUSa3b8b0c664c142` reports `tray_info_idx=P8a85d5a` —
+ * and a local preset (`local_68`) has no printer-side id at all, so neither can
+ * be checked here and both keep the stored name. Same for a slot reporting no
+ * id (generic filament with no tag), which is the case the row exists for.
+ */
+export function slotPresetDescribesTray(
+  presetId: string | null | undefined,
+  trayInfoIdx: string | null | undefined,
+): boolean {
+  const preset = (presetId || '').split('_')[0].toUpperCase();
+  const tray = (trayInfoIdx || '').split('_')[0].toUpperCase();
+  if (!preset.startsWith('GFS') || !tray.startsWith('GF') || tray.startsWith('GFS')) return true;
+  return `GF${preset.slice(3)}` === tray;
+}
+
 export interface AmsTrayLike {
   id: number;
   tray_type: string | null | undefined;

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-1thJVCSQ.js


+ 1 - 1
static/index.html

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

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů