소스 검색

SpoolBuddy: the kiosk works with its own device (#3504)

- the Write page sent every write to the first registered device,
  which could be another kiosk or a reader long offline; the layout's
  online state and display settings and the Settings page did the same
- a kiosk now uses the device chosen on it, else the online one; Write
  offers a choice when more than one is online and remembers it
- the kiosk state ignores weight, tag and online events from other
  devices, which every kiosk received
maziggy 1 일 전
부모
커밋
a5f15313c2

+ 66 - 0
frontend/src/__tests__/hooks/useKioskDevice.test.ts

@@ -0,0 +1,66 @@
+/**
+ * Which SpoolBuddy device a kiosk works with (#3504): not simply the first
+ * registered one, which may be an old reader long offline.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { createElement, type ReactNode } from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { pickKioskDevice, useKioskDevice } from '../../hooks/useKioskDevice';
+import { spoolbuddyApi, type SpoolBuddyDevice } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  spoolbuddyApi: { getDevices: vi.fn() },
+}));
+
+const dev = (device_id: string, online: boolean) => ({ device_id, hostname: `${device_id}.local`, online }) as SpoolBuddyDevice;
+
+describe('pickKioskDevice', () => {
+  const oldOffline = dev('old', false);
+  const kiosk = dev('kiosk', true);
+  const other = dev('other', true);
+
+  it('takes the online device over an offline one listed first', () => {
+    expect(pickKioskDevice([oldOffline, kiosk], null)).toBe(kiosk);
+  });
+
+  it('takes the device chosen on this kiosk, even while it is offline', () => {
+    expect(pickKioskDevice([kiosk, oldOffline], 'old')).toBe(oldOffline);
+    expect(pickKioskDevice([kiosk, other], 'other')).toBe(other);
+  });
+
+  it('ignores a chosen device that is no longer registered', () => {
+    expect(pickKioskDevice([oldOffline, kiosk], 'gone')).toBe(kiosk);
+  });
+
+  it('falls back to the first device when none is online, and to none without devices', () => {
+    expect(pickKioskDevice([oldOffline, dev('b', false)], null)).toBe(oldOffline);
+    expect(pickKioskDevice([], null)).toBeUndefined();
+  });
+});
+
+describe('useKioskDevice', () => {
+  beforeEach(() => {
+    localStorage.clear();
+    vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([dev('a', true), dev('b', true)]);
+  });
+
+  // One client for both hooks, as on the page.
+  const client = new QueryClient();
+  function wrapper({ children }: { children: ReactNode }) {
+    return createElement(QueryClientProvider, { client }, children);
+  }
+
+  it('remembers a choice and shares it with the other users on the page', async () => {
+    const page = renderHook(() => useKioskDevice(), { wrapper });
+    const layout = renderHook(() => useKioskDevice(), { wrapper });
+    await waitFor(() => expect(page.result.current.device?.device_id).toBe('a'));
+
+    act(() => page.result.current.chooseDevice('b'));
+
+    expect(page.result.current.device?.device_id).toBe('b');
+    await waitFor(() => expect(layout.result.current.device?.device_id).toBe('b'));
+    expect(localStorage.setItem).toHaveBeenCalledWith('spoolbuddy.kioskDeviceId', 'b');
+  });
+});

+ 53 - 0
frontend/src/__tests__/hooks/useSpoolBuddyState.test.ts

@@ -459,4 +459,57 @@ describe('useSpoolBuddyState', () => {
     expect(removedEvents).toContain('spoolbuddy-online');
     expect(removedEvents).toContain('spoolbuddy-offline');
   });
+
+  describe('with the kiosk device given (#3504)', () => {
+    it("ignores another device's weight, tags and online state", () => {
+      const { result } = renderHook(() => useSpoolBuddyState('kiosk'));
+
+      act(() => {
+        dispatchCustomEvent('spoolbuddy-weight', { weight_grams: 999, stable: true, device_id: 'other' });
+        dispatchCustomEvent('spoolbuddy-unknown-tag', { tag_uid: 'AABB', device_id: 'other' });
+        dispatchCustomEvent('spoolbuddy-online', { device_id: 'other' });
+      });
+
+      expect(result.current.weight).toBeNull();
+      expect(result.current.unknownTagUid).toBeNull();
+      expect(result.current.deviceOnline).toBe(false);
+    });
+
+    it("follows its own device's events", () => {
+      const { result } = renderHook(() => useSpoolBuddyState('kiosk'));
+
+      act(() => {
+        dispatchCustomEvent('spoolbuddy-weight', { weight_grams: 250, stable: true, device_id: 'kiosk' });
+      });
+
+      expect(result.current.weight).toBe(250);
+      expect(result.current.deviceId).toBe('kiosk');
+    });
+
+    it("keeps its own reading when another device's event arrives", () => {
+      const { result } = renderHook(() => useSpoolBuddyState('kiosk'));
+
+      act(() => {
+        dispatchCustomEvent('spoolbuddy-weight', { weight_grams: 250, stable: true, device_id: 'kiosk' });
+        dispatchCustomEvent('spoolbuddy-weight', { weight_grams: 999, stable: true, device_id: 'other' });
+      });
+
+      expect(result.current.weight).toBe(250);
+      expect(result.current.deviceOnline).toBe(true);
+    });
+
+    it('drops what another device left once the kiosk device is known', () => {
+      const { result, rerender } = renderHook(({ id }) => useSpoolBuddyState(id), {
+        initialProps: { id: undefined as string | undefined },
+      });
+      act(() => {
+        dispatchCustomEvent('spoolbuddy-weight', { weight_grams: 999, stable: true, device_id: 'other' });
+      });
+      expect(result.current.weight).toBe(999);
+
+      rerender({ id: 'kiosk' });
+
+      expect(result.current.weight).toBeNull();
+    });
+  });
 });

+ 85 - 0
frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx

@@ -534,4 +534,89 @@ describe('SpoolBuddyWriteTagPage', () => {
 
     expect(container.querySelector('#spool-material-number')).toBeNull();
   });
+
+  describe('which device writes (#3504)', () => {
+    const spool = {
+      id: 7,
+      material: 'PLA',
+      label_weight: 1000,
+      weight_used: 0,
+      tag_uid: null,
+      tray_uuid: null,
+      archived_at: null,
+      rgba: 'FF0000FF',
+    };
+
+    beforeEach(() => {
+      localStorage.clear();
+      vi.mocked(mockedApi.getSpools).mockResolvedValue([spool as never]);
+      mockOutletContext.sbState.deviceOnline = true;
+    });
+
+    async function selectSpool() {
+      await waitFor(() => expect(screen.getByText('PLA')).toBeDefined());
+      fireEvent.click(screen.getByText('PLA'));
+      await waitFor(() => expect((screen.getByText('Write Tag') as HTMLButtonElement).disabled).toBe(false));
+    }
+
+    async function writeSelectedSpool() {
+      await selectSpool();
+      fireEvent.click(screen.getByText('Write Tag'));
+    }
+
+    it('sends the write to the online device, not an offline one listed first', async () => {
+      vi.mocked(mockedSpoolbuddyApi.getDevices).mockResolvedValue([
+        { device_id: 'old-console', hostname: 'old', online: false } as never,
+        { device_id: 'kiosk', hostname: 'kiosk', online: true } as never,
+      ]);
+      renderPage();
+
+      await writeSelectedSpool();
+
+      await waitFor(() => expect(mockedSpoolbuddyApi.writeTag).toHaveBeenCalledWith('kiosk', 7));
+      // One online device: nothing to choose.
+      expect(screen.queryByText('spoolbuddy.writeTag.writeWith')).toBeNull();
+      mockOutletContext.sbState.deviceOnline = false;
+    });
+
+    it('offers a choice with two online devices and writes with the chosen one', async () => {
+      vi.mocked(mockedSpoolbuddyApi.getDevices).mockResolvedValue([
+        { device_id: 'left', hostname: 'left', online: true } as never,
+        { device_id: 'right', hostname: 'right', online: true } as never,
+      ]);
+      renderPage();
+
+      const picker = await screen.findByRole('combobox');
+      fireEvent.change(picker, { target: { value: 'right' } });
+      await writeSelectedSpool();
+
+      await waitFor(() => expect(mockedSpoolbuddyApi.writeTag).toHaveBeenCalledWith('right', 7));
+      expect(localStorage.setItem).toHaveBeenCalledWith('spoolbuddy.kioskDeviceId', 'right');
+      mockOutletContext.sbState.deviceOnline = false;
+    });
+
+    it("ignores a tag on another device's reader", async () => {
+      vi.mocked(mockedSpoolbuddyApi.getDevices).mockResolvedValue([
+        { device_id: 'kiosk', hostname: 'kiosk', online: true } as never,
+      ]);
+      renderPage();
+      await selectSpool();
+
+      act(() => {
+        window.dispatchEvent(
+          new CustomEvent('spoolbuddy-unknown-tag', { detail: { tag_uid: '04FFEE', sak: 0, device_id: 'elsewhere' } }),
+        );
+      });
+      expect(screen.queryByText('Tag detected — ready to write')).toBeNull();
+
+      act(() => {
+        window.dispatchEvent(
+          new CustomEvent('spoolbuddy-unknown-tag', { detail: { tag_uid: '04FFEE', sak: 0, device_id: 'kiosk' } }),
+        );
+      });
+      await waitFor(() => expect(screen.getByText('Tag detected — ready to write')).toBeDefined());
+      expect(screen.getByText('04FFEE')).toBeDefined();
+      mockOutletContext.sbState.deviceOnline = false;
+    });
+  });
 });

+ 6 - 8
frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx

@@ -7,6 +7,7 @@ import { SpoolBuddyBottomNav } from './SpoolBuddyBottomNav';
 import { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';
 import { SpoolBuddyQuickMenu } from './SpoolBuddyQuickMenu';
 import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
+import { useKioskDevice } from '../../hooks/useKioskDevice';
 import { useColorCatalogVersion } from '../../hooks/useColorCatalogVersion';
 import { api, spoolbuddyApi, type Printer, type PrinterStatus } from '../../api/client';
 import { VirtualKeyboard } from '../VirtualKeyboard';
@@ -25,7 +26,10 @@ export function SpoolBuddyLayout() {
   const { t, i18n } = useTranslation();
   const navigate = useNavigate();
   const location = useLocation();
-  const sbState = useSpoolBuddyState();
+  // This kiosk's device, for display settings, the online state and which
+  // events count on any page; not simply the first registered one (#3504).
+  const { device } = useKioskDevice(30000);
+  const sbState = useSpoolBuddyState(device?.device_id);
 
   // Hide the global toast viewport on the kiosk display. Restore on unmount so
   // navigating back to the main app sees its toasts again.
@@ -43,13 +47,7 @@ export function SpoolBuddyLayout() {
     }
   }, [appSettings?.language, i18n]);
 
-  // Query device data to initialize display settings on any page
-  const { data: devices = [] } = useQuery({
-    queryKey: ['spoolbuddy-devices'],
-    queryFn: () => spoolbuddyApi.getDevices(),
-    refetchInterval: 30000,
-  });
-  const device = devices[0];
+  // sbState only follows this device, so either says whether it is online.
   const effectiveDeviceOnline = sbState.deviceOnline || Boolean(device?.online);
   const sbStateForUi = useMemo(
     () => ({ ...sbState, deviceOnline: effectiveDeviceOnline }),

+ 65 - 0
frontend/src/hooks/useKioskDevice.ts

@@ -0,0 +1,65 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { spoolbuddyApi, type SpoolBuddyDevice } from '../api/client';
+
+/**
+ * Which SpoolBuddy device this kiosk works with (#3504).
+ *
+ * The kiosk page doesn't know which device it runs on: its URL carries an API
+ * key, which several devices may share, and the device list is ordered by
+ * registration. Taking the first device sent writes to a reader somewhere
+ * else, often an old one that went offline long ago.
+ */
+
+const STORAGE_KEY = 'spoolbuddy.kioskDeviceId';
+// Tells the other users of the hook on this page that the choice changed.
+const CHANGE_EVENT = 'spoolbuddy-kiosk-device-changed';
+
+// The choice when the browser won't store it, so this page still shares it.
+let unstoredId: string | null = null;
+
+function readStoredId(): string | null {
+  try {
+    return localStorage.getItem(STORAGE_KEY) ?? unstoredId;
+  } catch {
+    return unstoredId;
+  }
+}
+
+/**
+ * The device chosen on this kiosk while it is still registered, else the
+ * only online device, else the first online one, else the first registered.
+ */
+export function pickKioskDevice(devices: SpoolBuddyDevice[], storedId: string | null): SpoolBuddyDevice | undefined {
+  const stored = storedId ? devices.find((d) => d.device_id === storedId) : undefined;
+  return stored ?? devices.find((d) => d.online) ?? devices[0];
+}
+
+export function useKioskDevice(refetchInterval = 10000) {
+  const { data: devices = [] } = useQuery({
+    queryKey: ['spoolbuddy-devices'],
+    queryFn: () => spoolbuddyApi.getDevices(),
+    refetchInterval,
+  });
+  const [storedId, setStoredId] = useState<string | null>(readStoredId);
+
+  useEffect(() => {
+    const sync = () => setStoredId(readStoredId());
+    window.addEventListener(CHANGE_EVENT, sync);
+    return () => window.removeEventListener(CHANGE_EVENT, sync);
+  }, []);
+
+  const chooseDevice = useCallback((deviceId: string) => {
+    unstoredId = deviceId;
+    try {
+      localStorage.setItem(STORAGE_KEY, deviceId);
+    } catch {
+      // Not stored: the choice lasts until the page reloads.
+    }
+    setStoredId(deviceId);
+    window.dispatchEvent(new Event(CHANGE_EVENT));
+  }, []);
+
+  const device = useMemo(() => pickKioskDevice(devices, storedId), [devices, storedId]);
+  return { device, devices, chooseDevice };
+}

+ 33 - 9
frontend/src/hooks/useSpoolBuddyState.ts

@@ -1,4 +1,4 @@
-import { useEffect, useReducer, useCallback } from 'react';
+import { useEffect, useLayoutEffect, useReducer, useCallback, useRef } from 'react';
 
 export interface MatchedSpool {
   id: number;
@@ -132,11 +132,30 @@ function reducer(state: SpoolBuddyState, action: Action): SpoolBuddyState {
   }
 }
 
-export function useSpoolBuddyState() {
-  const [state, dispatch] = useReducer(reducer, initialState);
+/**
+ * The live state of a SpoolBuddy kiosk from the WebSocket events.
+ *
+ * Events from every device reach every kiosk, so with *deviceId* (the
+ * kiosk's own device, see useKioskDevice) the others are ignored: their
+ * weight, tags and online state belong to another reader (#3504).
+ */
+export function useSpoolBuddyState(deviceId?: string | null) {
+  const [rawState, dispatch] = useReducer(reducer, initialState);
+  const deviceIdRef = useRef(deviceId);
+  useLayoutEffect(() => {
+    deviceIdRef.current = deviceId;
+  }, [deviceId]);
+  const fromOtherDevice = useCallback(
+    (detail: { device_id?: string } | null | undefined) =>
+      !!deviceIdRef.current && !!detail?.device_id && detail.device_id !== deviceIdRef.current,
+    [],
+  );
+  // State that the previous device left behind is not this one's.
+  const state = deviceId && rawState.deviceId && rawState.deviceId !== deviceId ? initialState : rawState;
 
   const handleWeight = useCallback((e: Event) => {
     const detail = (e as CustomEvent).detail;
+    if (fromOtherDevice(detail)) return;
     dispatch({
       type: 'WEIGHT_UPDATE',
       weight: detail.weight_grams,
@@ -144,10 +163,11 @@ export function useSpoolBuddyState() {
       rawAdc: detail.raw_adc ?? null,
       deviceId: detail.device_id ?? '',
     });
-  }, []);
+  }, [fromOtherDevice]);
 
   const handleTagMatched = useCallback((e: Event) => {
     const detail = (e as CustomEvent).detail;
+    if (fromOtherDevice(detail)) return;
     const spool = detail.spool;
     if (spool) {
       dispatch({
@@ -170,10 +190,11 @@ export function useSpoolBuddyState() {
         deviceId: detail.device_id ?? '',
       });
     }
-  }, []);
+  }, [fromOtherDevice]);
 
   const handleUnknownTag = useCallback((e: Event) => {
     const detail = (e as CustomEvent).detail;
+    if (fromOtherDevice(detail)) return;
     dispatch({
       type: 'UNKNOWN_TAG',
       tagUid: detail.tag_uid ?? '',
@@ -181,31 +202,34 @@ export function useSpoolBuddyState() {
       tagData: detail.tag_data ?? null,
       deviceId: detail.device_id ?? '',
     });
-  }, []);
+  }, [fromOtherDevice]);
 
   const handleTagRemoved = useCallback((e: Event) => {
     const detail = (e as CustomEvent).detail;
+    if (fromOtherDevice(detail)) return;
     dispatch({
       type: 'TAG_REMOVED',
       deviceId: detail.device_id ?? '',
     });
-  }, []);
+  }, [fromOtherDevice]);
 
   const handleOnline = useCallback((e: Event) => {
     const detail = (e as CustomEvent).detail;
+    if (fromOtherDevice(detail)) return;
     dispatch({
       type: 'DEVICE_ONLINE',
       deviceId: detail.device_id ?? '',
     });
-  }, []);
+  }, [fromOtherDevice]);
 
   const handleOffline = useCallback((e: Event) => {
     const detail = (e as CustomEvent).detail;
+    if (fromOtherDevice(detail)) return;
     dispatch({
       type: 'DEVICE_OFFLINE',
       deviceId: detail.device_id ?? '',
     });
-  }, []);
+  }, [fromOtherDevice]);
 
   useEffect(() => {
     window.addEventListener('spoolbuddy-weight', handleWeight);

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

@@ -7712,6 +7712,8 @@ export default {
       includeBeta: 'Beta-Versionen einschließen',
     },
     writeTag: {
+      writeWith: 'Schreiben mit',
+      deviceOfflineOption: '{{name}} (Gerät offline)',
       tabExisting: 'Vorhandene Spule',
       tabNew: 'Neue Spule',
       tabReplace: 'Tag ersetzen',

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

@@ -7765,6 +7765,8 @@ export default {
       includeBeta: 'Include beta versions',
     },
     writeTag: {
+      writeWith: 'Write with',
+      deviceOfflineOption: '{{name}} (offline)',
       tabExisting: 'Existing Spool',
       tabNew: 'New Spool',
       tabReplace: 'Replace Tag',

+ 2 - 0
frontend/src/i18n/locales/es.ts

@@ -7720,6 +7720,8 @@ export default {
       includeBeta: 'Incluir versiones beta',
     },
     writeTag: {
+      writeWith: 'Escribir con',
+      deviceOfflineOption: '{{name}} (sin conexión)',
       tabExisting: 'Bobina existente',
       tabNew: 'Bobina nueva',
       tabReplace: 'Reemplazar etiqueta',

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

@@ -7700,6 +7700,8 @@ export default {
       testReadTag: 'Lire un tag',
     },
     writeTag: {
+      writeWith: 'Écrire avec',
+      deviceOfflineOption: '{{name}} (hors ligne)',
       tabExisting: 'Bobine existante',
       tabNew: 'Nouvelle bobine',
       tabReplace: 'Remplacer le tag',

+ 2 - 0
frontend/src/i18n/locales/it.ts

@@ -7699,6 +7699,8 @@ export default {
       testReadTag: 'Leggi tag',
     },
     writeTag: {
+      writeWith: 'Scrivi con',
+      deviceOfflineOption: '{{name}} (dispositivo offline)',
       tabExisting: 'Bobina esistente',
       tabNew: 'Nuova bobina',
       tabReplace: 'Sostituisci tag',

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

@@ -7713,6 +7713,8 @@ export default {
       testReadTag: 'タグを読み取り',
     },
     writeTag: {
+      writeWith: '書き込みに使うデバイス',
+      deviceOfflineOption: '{{name}}(オフライン)',
       tabExisting: '既存のスプール',
       tabNew: '新規スプール',
       tabReplace: 'タグ交換',

+ 2 - 0
frontend/src/i18n/locales/ko.ts

@@ -7355,6 +7355,8 @@ export default {
       includeBeta: '베타 버전 포함'
     },
     writeTag: {
+      writeWith: '쓰기 장치',
+      deviceOfflineOption: '{{name}} (오프라인)',
       tabExisting: '기존 스풀',
       tabNew: '새 스풀',
       tabReplace: '태그 교체',

+ 2 - 0
frontend/src/i18n/locales/nl.ts

@@ -7763,6 +7763,8 @@ export default {
       includeBeta: 'Bètaversies opnemen',
     },
     writeTag: {
+      writeWith: 'Schrijven met',
+      deviceOfflineOption: '{{name}} (apparaat offline)',
       tabExisting: 'Bestaande spoel',
       tabNew: 'Nieuwe spoel',
       tabReplace: 'Tag vervangen',

+ 2 - 0
frontend/src/i18n/locales/pl.ts

@@ -7765,6 +7765,8 @@ export default {
       includeBeta: 'Uwzględnij wersje beta',
     },
     writeTag: {
+      writeWith: 'Zapisz za pomocą',
+      deviceOfflineOption: '{{name}} (urządzenie offline)',
       tabExisting: 'Istniejąca szpula',
       tabNew: 'Nowa szpula',
       tabReplace: 'Zastąp tag',

+ 2 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -7699,6 +7699,8 @@ export default {
       testReadTag: 'Ler tag',
     },
     writeTag: {
+      writeWith: 'Gravar com',
+      deviceOfflineOption: '{{name}} (dispositivo offline)',
       tabExisting: 'Bobina existente',
       tabNew: 'Nova bobina',
       tabReplace: 'Substituir tag',

+ 2 - 0
frontend/src/i18n/locales/ru.ts

@@ -7343,6 +7343,8 @@ export default {
       includeBeta: "Включать бета-версии",
     },
     writeTag: {
+      writeWith: 'Записать с помощью',
+      deviceOfflineOption: '{{name}} (не в сети)',
       tabExisting: "Существующая катушка",
       tabNew: "Новая катушка",
       tabReplace: "Заменить метку",

+ 2 - 0
frontend/src/i18n/locales/sv.ts

@@ -7794,6 +7794,8 @@ errors: {
       includeBeta: 'Inkludera betaversioner',
     },
     writeTag: {
+      writeWith: 'Skriv med',
+      deviceOfflineOption: '{{name}} (enhet offline)',
       tabExisting: 'Befintlig rulle',
       tabNew: 'Ny rulle',
       tabReplace: 'Ersätt tagg',

+ 2 - 0
frontend/src/i18n/locales/tr.ts

@@ -7651,6 +7651,8 @@ export default {
       includeBeta: 'Beta sürümleri dahil et',
     },
     writeTag: {
+      writeWith: 'Şununla yaz',
+      deviceOfflineOption: '{{name}} (çevrimdışı)',
       tabExisting: 'Mevcut Makara',
       tabNew: 'Yeni Makara',
       tabReplace: 'Etiketi Değiştir',

+ 2 - 0
frontend/src/i18n/locales/uk.ts

@@ -7753,6 +7753,8 @@ export default {
       includeBeta: "Включати бета-версії",
     },
     writeTag: {
+      writeWith: 'Записати за допомогою',
+      deviceOfflineOption: '{{name}} (не в мережі)',
       tabExisting: "Наявна котушка",
       tabNew: "Нова котушка",
       tabReplace: "Замінити тег",

+ 2 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -7697,6 +7697,8 @@ export default {
       includeBeta: '包含测试版本',
     },
     writeTag: {
+      writeWith: '写入设备',
+      deviceOfflineOption: '{{name}}(离线)',
       tabExisting: '现有耗材',
       tabNew: '新耗材',
       tabReplace: '替换标签',

+ 2 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -7697,6 +7697,8 @@ export default {
       includeBeta: '包含測試版本',
     },
     writeTag: {
+      writeWith: '寫入裝置',
+      deviceOfflineOption: '{{name}}(離線)',
       tabExisting: '現有耗材',
       tabNew: '新耗材',
       tabReplace: '替換標籤',

+ 4 - 10
frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx

@@ -7,6 +7,7 @@ import { spoolbuddyApi, type SpoolBuddyDevice } from '../../api/client';
 import { DiagnosticModal } from '../../components/spoolbuddy/DiagnosticModal';
 import { FileText, Wand2, Zap } from 'lucide-react';
 import { parseUTCDate } from '../../utils/date';
+import { useKioskDevice } from '../../hooks/useKioskDevice';
 
 
 function formatUptime(seconds: number): string {
@@ -1058,16 +1059,9 @@ export function SpoolBuddySettingsPage() {
   const { t } = useTranslation();
   const [activeTab, setActiveTab] = useState<SettingsTab>('device');
 
-  const { data: devices = [] } = useQuery({
-    queryKey: ['spoolbuddy-devices'],
-    queryFn: () => spoolbuddyApi.getDevices(),
-    refetchInterval: 10000,
-  });
-
-  // Use first device (most common setup) or find one matching current state
-  const device = sbState.deviceId
-    ? devices.find((d) => d.device_id === sbState.deviceId) ?? devices[0]
-    : devices[0];
+  // This kiosk's device, not the one the last WebSocket event came from:
+  // every device's weight updates reach every kiosk (#3504).
+  const { device } = useKioskDevice(10000);
 
 
   const tabs: { id: SettingsTab; label: string }[] = [

+ 48 - 11
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback, useMemo } from 'react';
+import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
 import { useOutletContext } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
@@ -57,6 +57,7 @@ import {
 import { MATERIALS } from '../../components/spool-form/constants';
 import { NumberInput } from '../../components/NumberInput';
 import { useUiSettings } from '../../hooks/useUiSettings';
+import { useKioskDevice } from '../../hooks/useKioskDevice';
 
 type Tab = 'existing' | 'new' | 'replace';
 type WriteStatus = 'idle' | 'selected' | 'writing' | 'success' | 'error';
@@ -105,16 +106,26 @@ export function SpoolBuddyWriteTagPage() {
     enabled: spoolmanModeReady,
   });
 
-  const { data: devices = [] } = useQuery({
-    queryKey: ['spoolbuddy-devices'],
-    queryFn: () => spoolbuddyApi.getDevices(),
-    refetchInterval: 5000,
-  });
+  // The write goes to this kiosk's device, not the first one registered,
+  // which may be an old reader long offline (#3504).
+  const { device, devices, chooseDevice } = useKioskDevice(5000);
 
   const { data: settings } = useUiSettings();
 
-  const device = devices[0];
+  // The layout's state follows the same device (useKioskDevice).
   const deviceOnline = sbState.deviceOnline;
+  // Only when there is a real choice: another device besides this one online.
+  const showDevicePicker = devices.length > 1 && devices.some((d) => d.online && d.device_id !== device?.device_id);
+  // Tag events from every reader reach every kiosk; only this device's count.
+  const deviceIdRef = useRef(device?.device_id);
+  useEffect(() => {
+    deviceIdRef.current = device?.device_id;
+  }, [device?.device_id]);
+  const fromOtherDevice = useCallback(
+    (detail: { device_id?: string } | null | undefined) =>
+      !!deviceIdRef.current && !!detail?.device_id && detail.device_id !== deviceIdRef.current,
+    [],
+  );
   const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
 
   // Filter spools based on tab
@@ -155,24 +166,33 @@ export function SpoolBuddyWriteTagPage() {
   // Listen for tag events
   const handleUnknownTag = useCallback((e: Event) => {
     const detail = (e as CustomEvent).detail;
+    if (fromOtherDevice(detail)) return;
     const sak = detail.sak;
     if (sak === 0x00) {
       setTagOnReader(true);
       setTagUid(detail.tag_uid ?? null);
     }
-  }, []);
+  }, [fromOtherDevice]);
+
+  // A tag seen on another device is not on this one's reader.
+  useEffect(() => {
+    setTagOnReader(false);
+    setTagUid(null);
+  }, [device?.device_id]);
 
   const handleTagMatched = useCallback((e: Event) => {
     const detail = (e as CustomEvent).detail;
+    if (fromOtherDevice(detail)) return;
     // Tag is on the reader — could be used for replace flow
     setTagOnReader(true);
     setTagUid(detail.tag_uid ?? null);
-  }, []);
+  }, [fromOtherDevice]);
 
-  const handleTagRemoved = useCallback(() => {
+  const handleTagRemoved = useCallback((e: Event) => {
+    if (fromOtherDevice((e as CustomEvent).detail)) return;
     setTagOnReader(false);
     setTagUid(null);
-  }, []);
+  }, [fromOtherDevice]);
 
   const handleTagWritten = useCallback((e: Event) => {
     const detail = (e as CustomEvent).detail;
@@ -393,6 +413,23 @@ export function SpoolBuddyWriteTagPage() {
 
         {/* Right panel — NFC status + write action */}
         <div className="w-[340px] flex flex-col items-center justify-center p-6 shrink-0">
+          {showDevicePicker && (
+            <label className="w-full mb-4 text-sm text-white/60">
+              {t('spoolbuddy.writeTag.writeWith')}
+              <select
+                value={device?.device_id ?? ''}
+                onChange={(e) => chooseDevice(e.target.value)}
+                disabled={writeStatus === 'writing'}
+                className="mt-1 w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white"
+              >
+                {devices.map((d) => (
+                  <option key={d.device_id} value={d.device_id}>
+                    {d.online ? d.hostname : t('spoolbuddy.writeTag.deviceOfflineOption', { name: d.hostname })}
+                  </option>
+                ))}
+              </select>
+            </label>
+          )}
           <NfcStatusPanel
             writeStatus={writeStatus}
             writeMessage={writeMessage}