Browse Source

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 23 hours ago
parent
commit
9712ab9420

+ 1 - 0
CHANGELOG.md

@@ -21,6 +21,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **"Any model" jobs go to the printer with the fewest print hours (#3480, sponsor vote Q4 2026)** — When several printers of a model can take a job, the queue used to pick the first one in its list, so on a farm the same printer got most of the work. It now picks the one with the fewest print hours (the Maintenance counter, including baseline hours entered there), so jobs and wear spread across the farm. Preferred colours still come first, and the queue powers on the least used printer too when it has to switch one on with an Auto Power On plug.
 - **"Any model" jobs go to the printer with the fewest print hours (#3480, sponsor vote Q4 2026)** — When several printers of a model can take a job, the queue used to pick the first one in its list, so on a farm the same printer got most of the work. It now picks the one with the fewest print hours (the Maintenance counter, including baseline hours entered there), so jobs and wear spread across the farm. Preferred colours still come first, and the queue powers on the least used printer too when it has to switch one on with an Auto Power On plug.
 
 
 ### Fixed
 ### Fixed
+- **The SpoolBuddy kiosk works with its own device when several are registered (#3504, reported by @pleasantone)** — The kiosk's **Write** page always sent the write to the first registered device, which could be another kiosk or an old reader that had long been offline. The online state and display settings also came from that device, and every kiosk showed the weight and tags of every reader. The kiosk now works with one device: the one picked on its **Write** page, or else the device that is online. **Write** shows a choice of device when more than one is online, and the kiosk remembers it. Weight, tags and online state from other devices no longer show on it.
 - **A tag written with SpoolBuddy moves to the new spool (#3505, reported by @pleasantone)** — With Bambuddy's own inventory, writing a tag that already belonged to another spool linked it to the new spool but left it on the old one too, so scanning it could open the old spool. The tag now moves: the spool that had it gives it up, as it already did with Spoolman.
 - **A tag written with SpoolBuddy moves to the new spool (#3505, reported by @pleasantone)** — With Bambuddy's own inventory, writing a tag that already belonged to another spool linked it to the new spool but left it on the old one too, so scanning it could open the old spool. The tag now moves: the spool that had it gives it up, as it already did with Spoolman.
 - **SpoolBuddy checks a tag write instead of always reporting success (#3076, reported by @jinjiangz, gaps found by @pleasantone)** — The PN5180 can't see a tag's answer to a write, so **Write Tag** reported success for every page it sent. SpoolBuddy now reads the written data back and fails the write if the tag holds something else, naming the page. If the tag doesn't answer the read-back, the write still counts, reported as not verified. A tag too small for the data is refused before anything is written, and so is a tag that isn't NDEF-formatted, such as a blank MIFARE Ultralight, which looks like an NTAG to the reader. Before, a write past its end was reported as successful. When the write's result couldn't reach Bambuddy straight away, the next heartbeat brought the same write back and SpoolBuddy ran it a second time, on whatever tag was then on the reader. It now waits until the result is through.
 - **SpoolBuddy checks a tag write instead of always reporting success (#3076, reported by @jinjiangz, gaps found by @pleasantone)** — The PN5180 can't see a tag's answer to a write, so **Write Tag** reported success for every page it sent. SpoolBuddy now reads the written data back and fails the write if the tag holds something else, naming the page. If the tag doesn't answer the read-back, the write still counts, reported as not verified. A tag too small for the data is refused before anything is written, and so is a tag that isn't NDEF-formatted, such as a blank MIFARE Ultralight, which looks like an NTAG to the reader. Before, a write past its end was reported as successful. When the write's result couldn't reach Bambuddy straight away, the next heartbeat brought the same write back and SpoolBuddy ran it a second time, on whatever tag was then on the reader. It now waits until the result is through.
 - **HMS notifications are no longer sent again after a restart (#3502, reported by @pleasantone)** — The fix for #3486 remembers which printer errors were already notified, in a table that was never created: startup did not load its model, so every start logged "no such table: hms_notified_faults" and the errors were sent again. The table is now created on the next start.
 - **HMS notifications are no longer sent again after a restart (#3502, reported by @pleasantone)** — The fix for #3486 remembers which printer errors were already notified, in a table that was never created: startup did not load its model, so every start logged "no such table: hms_notified_faults" and the errors were sent again. The table is now created on the next start.

+ 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-online');
     expect(removedEvents).toContain('spoolbuddy-offline');
     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();
     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 { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';
 import { SpoolBuddyQuickMenu } from './SpoolBuddyQuickMenu';
 import { SpoolBuddyQuickMenu } from './SpoolBuddyQuickMenu';
 import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
 import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
+import { useKioskDevice } from '../../hooks/useKioskDevice';
 import { useColorCatalogVersion } from '../../hooks/useColorCatalogVersion';
 import { useColorCatalogVersion } from '../../hooks/useColorCatalogVersion';
 import { api, spoolbuddyApi, type Printer, type PrinterStatus } from '../../api/client';
 import { api, spoolbuddyApi, type Printer, type PrinterStatus } from '../../api/client';
 import { VirtualKeyboard } from '../VirtualKeyboard';
 import { VirtualKeyboard } from '../VirtualKeyboard';
@@ -25,7 +26,10 @@ export function SpoolBuddyLayout() {
   const { t, i18n } = useTranslation();
   const { t, i18n } = useTranslation();
   const navigate = useNavigate();
   const navigate = useNavigate();
   const location = useLocation();
   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
   // Hide the global toast viewport on the kiosk display. Restore on unmount so
   // navigating back to the main app sees its toasts again.
   // navigating back to the main app sees its toasts again.
@@ -43,13 +47,7 @@ export function SpoolBuddyLayout() {
     }
     }
   }, [appSettings?.language, i18n]);
   }, [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 effectiveDeviceOnline = sbState.deviceOnline || Boolean(device?.online);
   const sbStateForUi = useMemo(
   const sbStateForUi = useMemo(
     () => ({ ...sbState, deviceOnline: effectiveDeviceOnline }),
     () => ({ ...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 {
 export interface MatchedSpool {
   id: number;
   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 handleWeight = useCallback((e: Event) => {
     const detail = (e as CustomEvent).detail;
     const detail = (e as CustomEvent).detail;
+    if (fromOtherDevice(detail)) return;
     dispatch({
     dispatch({
       type: 'WEIGHT_UPDATE',
       type: 'WEIGHT_UPDATE',
       weight: detail.weight_grams,
       weight: detail.weight_grams,
@@ -144,10 +163,11 @@ export function useSpoolBuddyState() {
       rawAdc: detail.raw_adc ?? null,
       rawAdc: detail.raw_adc ?? null,
       deviceId: detail.device_id ?? '',
       deviceId: detail.device_id ?? '',
     });
     });
-  }, []);
+  }, [fromOtherDevice]);
 
 
   const handleTagMatched = useCallback((e: Event) => {
   const handleTagMatched = useCallback((e: Event) => {
     const detail = (e as CustomEvent).detail;
     const detail = (e as CustomEvent).detail;
+    if (fromOtherDevice(detail)) return;
     const spool = detail.spool;
     const spool = detail.spool;
     if (spool) {
     if (spool) {
       dispatch({
       dispatch({
@@ -170,10 +190,11 @@ export function useSpoolBuddyState() {
         deviceId: detail.device_id ?? '',
         deviceId: detail.device_id ?? '',
       });
       });
     }
     }
-  }, []);
+  }, [fromOtherDevice]);
 
 
   const handleUnknownTag = useCallback((e: Event) => {
   const handleUnknownTag = useCallback((e: Event) => {
     const detail = (e as CustomEvent).detail;
     const detail = (e as CustomEvent).detail;
+    if (fromOtherDevice(detail)) return;
     dispatch({
     dispatch({
       type: 'UNKNOWN_TAG',
       type: 'UNKNOWN_TAG',
       tagUid: detail.tag_uid ?? '',
       tagUid: detail.tag_uid ?? '',
@@ -181,31 +202,34 @@ export function useSpoolBuddyState() {
       tagData: detail.tag_data ?? null,
       tagData: detail.tag_data ?? null,
       deviceId: detail.device_id ?? '',
       deviceId: detail.device_id ?? '',
     });
     });
-  }, []);
+  }, [fromOtherDevice]);
 
 
   const handleTagRemoved = useCallback((e: Event) => {
   const handleTagRemoved = useCallback((e: Event) => {
     const detail = (e as CustomEvent).detail;
     const detail = (e as CustomEvent).detail;
+    if (fromOtherDevice(detail)) return;
     dispatch({
     dispatch({
       type: 'TAG_REMOVED',
       type: 'TAG_REMOVED',
       deviceId: detail.device_id ?? '',
       deviceId: detail.device_id ?? '',
     });
     });
-  }, []);
+  }, [fromOtherDevice]);
 
 
   const handleOnline = useCallback((e: Event) => {
   const handleOnline = useCallback((e: Event) => {
     const detail = (e as CustomEvent).detail;
     const detail = (e as CustomEvent).detail;
+    if (fromOtherDevice(detail)) return;
     dispatch({
     dispatch({
       type: 'DEVICE_ONLINE',
       type: 'DEVICE_ONLINE',
       deviceId: detail.device_id ?? '',
       deviceId: detail.device_id ?? '',
     });
     });
-  }, []);
+  }, [fromOtherDevice]);
 
 
   const handleOffline = useCallback((e: Event) => {
   const handleOffline = useCallback((e: Event) => {
     const detail = (e as CustomEvent).detail;
     const detail = (e as CustomEvent).detail;
+    if (fromOtherDevice(detail)) return;
     dispatch({
     dispatch({
       type: 'DEVICE_OFFLINE',
       type: 'DEVICE_OFFLINE',
       deviceId: detail.device_id ?? '',
       deviceId: detail.device_id ?? '',
     });
     });
-  }, []);
+  }, [fromOtherDevice]);
 
 
   useEffect(() => {
   useEffect(() => {
     window.addEventListener('spoolbuddy-weight', handleWeight);
     window.addEventListener('spoolbuddy-weight', handleWeight);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -7755,6 +7755,8 @@ export default {
       includeBeta: '包含測試版本',
       includeBeta: '包含測試版本',
     },
     },
     writeTag: {
     writeTag: {
+      writeWith: '寫入裝置',
+      deviceOfflineOption: '{{name}}(離線)',
       tabExisting: '現有耗材',
       tabExisting: '現有耗材',
       tabNew: '新耗材',
       tabNew: '新耗材',
       tabReplace: '替換標籤',
       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 { DiagnosticModal } from '../../components/spoolbuddy/DiagnosticModal';
 import { FileText, Wand2, Zap } from 'lucide-react';
 import { FileText, Wand2, Zap } from 'lucide-react';
 import { parseUTCDate } from '../../utils/date';
 import { parseUTCDate } from '../../utils/date';
+import { useKioskDevice } from '../../hooks/useKioskDevice';
 
 
 
 
 function formatUptime(seconds: number): string {
 function formatUptime(seconds: number): string {
@@ -1058,16 +1059,9 @@ export function SpoolBuddySettingsPage() {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [activeTab, setActiveTab] = useState<SettingsTab>('device');
   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 }[] = [
   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 { useOutletContext } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
 import { useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
@@ -57,6 +57,7 @@ import {
 import { MATERIALS } from '../../components/spool-form/constants';
 import { MATERIALS } from '../../components/spool-form/constants';
 import { NumberInput } from '../../components/NumberInput';
 import { NumberInput } from '../../components/NumberInput';
 import { useUiSettings } from '../../hooks/useUiSettings';
 import { useUiSettings } from '../../hooks/useUiSettings';
+import { useKioskDevice } from '../../hooks/useKioskDevice';
 
 
 type Tab = 'existing' | 'new' | 'replace';
 type Tab = 'existing' | 'new' | 'replace';
 type WriteStatus = 'idle' | 'selected' | 'writing' | 'success' | 'error';
 type WriteStatus = 'idle' | 'selected' | 'writing' | 'success' | 'error';
@@ -105,16 +106,26 @@ export function SpoolBuddyWriteTagPage() {
     enabled: spoolmanModeReady,
     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 { data: settings } = useUiSettings();
 
 
-  const device = devices[0];
+  // The layout's state follows the same device (useKioskDevice).
   const deviceOnline = sbState.deviceOnline;
   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');
   const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
 
 
   // Filter spools based on tab
   // Filter spools based on tab
@@ -155,24 +166,33 @@ export function SpoolBuddyWriteTagPage() {
   // Listen for tag events
   // Listen for tag events
   const handleUnknownTag = useCallback((e: Event) => {
   const handleUnknownTag = useCallback((e: Event) => {
     const detail = (e as CustomEvent).detail;
     const detail = (e as CustomEvent).detail;
+    if (fromOtherDevice(detail)) return;
     const sak = detail.sak;
     const sak = detail.sak;
     if (sak === 0x00) {
     if (sak === 0x00) {
       setTagOnReader(true);
       setTagOnReader(true);
       setTagUid(detail.tag_uid ?? null);
       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 handleTagMatched = useCallback((e: Event) => {
     const detail = (e as CustomEvent).detail;
     const detail = (e as CustomEvent).detail;
+    if (fromOtherDevice(detail)) return;
     // Tag is on the reader — could be used for replace flow
     // Tag is on the reader — could be used for replace flow
     setTagOnReader(true);
     setTagOnReader(true);
     setTagUid(detail.tag_uid ?? null);
     setTagUid(detail.tag_uid ?? null);
-  }, []);
+  }, [fromOtherDevice]);
 
 
-  const handleTagRemoved = useCallback(() => {
+  const handleTagRemoved = useCallback((e: Event) => {
+    if (fromOtherDevice((e as CustomEvent).detail)) return;
     setTagOnReader(false);
     setTagOnReader(false);
     setTagUid(null);
     setTagUid(null);
-  }, []);
+  }, [fromOtherDevice]);
 
 
   const handleTagWritten = useCallback((e: Event) => {
   const handleTagWritten = useCallback((e: Event) => {
     const detail = (e as CustomEvent).detail;
     const detail = (e as CustomEvent).detail;
@@ -393,6 +413,23 @@ export function SpoolBuddyWriteTagPage() {
 
 
         {/* Right panel — NFC status + write action */}
         {/* Right panel — NFC status + write action */}
         <div className="w-[340px] flex flex-col items-center justify-center p-6 shrink-0">
         <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
           <NfcStatusPanel
             writeStatus={writeStatus}
             writeStatus={writeStatus}
             writeMessage={writeMessage}
             writeMessage={writeMessage}