Browse Source

Fix SpoolBuddy tag_type for linked spools + add inventory weight check column

  SpoolBuddy's "Link to Spool" used the generic updateSpool API which only
  set tag_uid, leaving tag_type and data_origin empty. Now uses linkTagToSpool
  with tag_type='generic' and data_origin='nfc_link'.

  Added a "Weight Check" inventory column (hidden by default) that compares
  each spool's last scale measurement against calculated gross weight with
  ±50g tolerance. Shows green check for match, yellow warning + sync button
  for mismatch. Backend stores last_scale_weight and last_weighed_at on each
  spool when weight is synced via SpoolBuddy. Includes edge case handling
  when scale weight < core weight. i18n keys added for all 6 locales.
maziggy 2 months ago
parent
commit
a5016432df

+ 2 - 0
CHANGELOG.md

@@ -13,8 +13,10 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy Dashboard Redesign** — Redesigned the SpoolBuddy dashboard with a two-column layout: left column shows device connection status (scale and NFC with state-colored icons — green when device is online, gray when offline) and a compact printers list with live status indicators; right column shows the current spool card. Cards use a dashed border style for a cleaner look. The large weight display card was removed in favor of the inline scale reading in the device card.
 - **SpoolBuddy Kiosk Auth Bypass via API Key** — When Bambuddy auth is enabled, the SpoolBuddy kiosk (Chromium on RPi) was redirected to the login page because the `ProtectedRoute` requires a user object from `GET /auth/me`, which only accepted JWT tokens. The `/auth/me` endpoint now also accepts API keys (via `Authorization: Bearer bb_xxx` or `X-API-Key` header) and returns a synthetic admin user with all permissions. The frontend's `AuthContext` reads an optional `?token=` URL parameter on first load, stores it in localStorage, and strips it from the URL to prevent leakage via browser history or referrer. The install script now includes the API key in the kiosk URL (`/spoolbuddy?token=${API_KEY}`), so the device authenticates automatically on boot without manual login.
 - **Daily Beta Builds** — Added a release script (`docker-publish-daily-beta.sh`) that reads the current `APP_VERSION` from config, builds a multi-arch Docker image, pushes to both GHCR and Docker Hub, and creates/updates a GitHub prerelease with changelog notes. Daily builds overwrite the same beta version tag (e.g., `0.2.2b1`) — users pull the latest by re-pulling the tag or using Watchtower. Beta images are never tagged as `latest`.
+- **Inventory Scale Weight Check Column** — Added a "Weight Check" column (hidden by default) to the inventory table that compares each spool's last scale measurement against its calculated gross weight (net remaining + core weight). Spools within a ±50g tolerance show a green checkmark; mismatched spools show a yellow warning with the difference and a sync button that trusts the scale reading and resets weight tracking. The backend stores `last_scale_weight` and `last_weighed_at` on each spool whenever weight is synced via SpoolBuddy, and the column tooltip shows scale weight, calculated weight, and difference. Edge case: when scale weight is below core weight (empty spool or not on scale), the comparison treats it as a match since sync can't correct this.
 
 ### Fixed
+- **SpoolBuddy Link Tag Missing tag_type** — Linking an NFC tag to a spool via the SpoolBuddy dashboard's "Link to Spool" action only set `tag_uid` but left `tag_type` and `data_origin` empty, because it called the generic `updateSpool` API instead of the dedicated `linkTagToSpool` endpoint. The printer card's `LinkSpoolModal` already used `linkTagToSpool` correctly. Now uses `linkTagToSpool` with `tag_type: 'generic'` and `data_origin: 'nfc_link'`, which also handles conflict checks and archived tag recycling.
 - **Printer Card Loses Info When Print Is Paused** ([#562](https://github.com/maziggy/bambuddy/issues/562)) — When a print was paused (via G-code pause command or user action), the printer card showed the print as finished — the progress bar, print name, ETA, layer count, and cover image all disappeared, replaced by the idle "Ready to Print" placeholder. The display conditions only checked for `state === 'RUNNING'` but not `'PAUSE'`, even though other parts of the same page (Skip Objects button, Stop/Resume controls) already handled both states correctly. Now shows print progress info for both `RUNNING` and `PAUSE` states, and the status label correctly reads "Paused" instead of the hardcoded "Printing" fallback.
 - **SpoolBuddy "Assign to AMS" Slot Shows Empty Fields in Slicer** — After assigning a spool to an AMS slot via SpoolBuddy's "Assign to AMS" button, the slicer's slot overview showed the correct filament, but opening the slot detail card showed all fields empty/unselected. Two bugs: (1) the `assign_spool` backend called the cloud API with the raw `slicer_filament` value including its version suffix (e.g., `PFUS9ac902733670a9_07`), which returned a 404; the silent fallback sent the `setting_id` as `tray_info_idx` instead of the real `filament_id` (e.g., `PFUS9ac902733670a9` instead of `P4d64437`), and the slicer couldn't resolve the preset; (2) no `SlotPresetMapping` was saved, so Bambuddy's own ConfigureAmsSlotModal couldn't identify the active preset when reopened. Now strips version suffixes before the cloud lookup, resolves the real `filament_id` via the cloud API (with local preset and generic ID fallbacks), includes the brand name in `tray_sub_brands`, and saves the slot preset mapping from the frontend after assignment.
 - **Virtual Printer Bind Server Fails With TLS-Enabled Slicers** ([#559](https://github.com/maziggy/bambuddy/issues/559)) — BambuStudio uses TLS on port 3002 for certain printer models (e.g. A1 Mini / N1), but the bind server only spoke plain TCP on both ports 3000 and 3002. The slicer's TLS ClientHello was rejected as an "invalid frame", preventing discovery and connection entirely. Port 3002 now uses TLS (using the VP's existing certificate), while port 3000 remains plain TCP for backwards compatibility. The proxy-mode bind proxy was also updated to use TLS termination on port 3002.

+ 2 - 0
backend/app/api/routes/spoolbuddy.py

@@ -275,6 +275,8 @@ async def update_spool_weight(
     # net weight = total on scale minus empty spool core
     net_filament = max(0, req.weight_grams - spool.core_weight)
     spool.weight_used = max(0, spool.label_weight - net_filament)
+    spool.last_scale_weight = req.weight_grams
+    spool.last_weighed_at = datetime.now(timezone.utc)
     await db.commit()
 
     logger.info(

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

@@ -1230,6 +1230,16 @@ async def run_migrations(conn):
     except OperationalError:
         pass  # Already applied
 
+    # Migration: Add SpoolBuddy scale weight tracking columns to spool table
+    try:
+        await conn.execute(text("ALTER TABLE spool ADD COLUMN last_scale_weight INTEGER"))
+    except OperationalError:
+        pass  # Already applied
+    try:
+        await conn.execute(text("ALTER TABLE spool ADD COLUMN last_weighed_at DATETIME"))
+    except OperationalError:
+        pass  # Already applied
+
     # Migration: Add cost tracking fields to spool table
     try:
         await conn.execute(text("ALTER TABLE spool ADD COLUMN cost_per_kg REAL"))

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

@@ -24,6 +24,8 @@ class Spool(Base):
     )  # Reference to spool_catalog entry for core weight
     weight_used: Mapped[float] = mapped_column(Float, default=0)  # Consumed grams
     weight_locked: Mapped[bool] = mapped_column(Boolean, default=False)  # Lock weight from AMS auto-sync
+    last_scale_weight: Mapped[int | None] = mapped_column(Integer)  # Last gross weight from scale (g)
+    last_weighed_at: Mapped[datetime | None] = mapped_column(DateTime)  # When last weighed
     slicer_filament: Mapped[str | None] = mapped_column(String(50))  # Preset ID (e.g. "GFL99")
     slicer_filament_name: Mapped[str | None] = mapped_column(String(100))  # Preset name for slicer
     nozzle_temp_min: Mapped[int | None] = mapped_column()  # Override min temp

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

@@ -24,6 +24,8 @@ class SpoolBase(BaseModel):
     tag_type: str | None = None
     cost_per_kg: float | None = Field(default=None, ge=0)
     weight_locked: bool = False
+    last_scale_weight: int | None = None
+    last_weighed_at: datetime | None = None
 
 
 class SpoolCreate(SpoolBase):

+ 19 - 0
backend/tests/integration/test_spoolbuddy.py

@@ -371,6 +371,25 @@ class TestScaleEndpoints:
         data = resp.json()
         assert data["weight_used"] == 0
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_spool_weight_stores_scale_reading(self, async_client: AsyncClient, spool_factory):
+        """Verify last_scale_weight and last_weighed_at are stored after weight sync."""
+        spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0)
+
+        resp = await async_client.post(
+            f"{API}/scale/update-spool-weight",
+            json={"spool_id": spool.id, "weight_grams": 750},
+        )
+        assert resp.status_code == 200
+
+        # Fetch the spool via inventory API to verify stored fields
+        spool_resp = await async_client.get(f"/api/v1/inventory/spools/{spool.id}")
+        assert spool_resp.status_code == 200
+        spool_data = spool_resp.json()
+        assert spool_data["last_scale_weight"] == 750
+        assert spool_data["last_weighed_at"] is not None
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_update_spool_weight_missing_spool_404(self, async_client: AsyncClient):

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

@@ -1820,6 +1820,8 @@ export interface InventorySpool {
   created_at: string;
   updated_at: string;
   cost_per_kg: number | null;
+  last_scale_weight: number | null;
+  last_weighed_at: string | null;
   k_profiles?: SpoolKProfile[];
 }
 

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

@@ -2692,6 +2692,9 @@ export default {
     loadedInAms: 'Im AMS/Ext geladen',
     remaining: 'Verbleibend',
     lowStockThreshold: '<20% verbleibend',
+    weightCheck: 'Gewichtskontrolle',
+    lastWeighed: 'Zuletzt gewogen',
+    neverWeighed: 'Nie gewogen',
     search: 'Spulen suchen...',
     showing: 'Zeige',
     to: 'bis',

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

@@ -2696,6 +2696,9 @@ export default {
     loadedInAms: 'Loaded in AMS/Ext',
     remaining: 'Remaining',
     lowStockThreshold: '<20% remaining',
+    weightCheck: 'Weight Check',
+    lastWeighed: 'Last weighed',
+    neverWeighed: 'Never weighed',
     search: 'Search spools...',
     showing: 'Showing',
     to: 'to',

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

@@ -2684,6 +2684,9 @@ export default {
     loadedInAms: 'Chargé dans AMS/Ext',
     remaining: 'Restant',
     lowStockThreshold: '<20% restant',
+    weightCheck: 'Vérification poids',
+    lastWeighed: 'Dernière pesée',
+    neverWeighed: 'Jamais pesé',
     search: 'Chercher bobines...',
     showing: 'Affichage',
     to: 'à',

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

@@ -2454,6 +2454,9 @@ export default {
     spoolRestored: 'Bobina ripristinata',
     deleteConfirm: 'Sei sicuro di voler eliminare questa bobina? Questa azione non può essere annullata.',
     advancedSettings: 'Impostazioni Avanzate',
+    weightCheck: 'Controllo Peso',
+    lastWeighed: 'Ultima pesatura',
+    neverWeighed: 'Mai pesato',
   },
 
   // Timelapse

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

@@ -2618,6 +2618,9 @@ export default {
     loadedInAms: 'AMS/Extに装填中',
     remaining: '残り',
     lowStockThreshold: '残り20%未満',
+    weightCheck: '重量チェック',
+    lastWeighed: '最終計量',
+    neverWeighed: '未計量',
     search: 'スプールを検索...',
     showing: '表示',
     to: '〜',

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

@@ -2696,6 +2696,9 @@ export default {
     loadedInAms: 'Carregado no AMS/Ext',
     remaining: 'Restante',
     lowStockThreshold: '<20% restante',
+    weightCheck: 'Verificação de Peso',
+    lastWeighed: 'Última pesagem',
+    neverWeighed: 'Nunca pesado',
     search: 'Pesquisar carretéis...',
     showing: 'Mostrando',
     to: 'até',

+ 85 - 7
frontend/src/pages/InventoryPage.tsx

@@ -5,9 +5,9 @@ import {
   Plus, Loader2, Trash2, Archive, RotateCcw, Edit2, Package,
   Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
   TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
-  ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown,
+  ArrowUp, ArrowDown, ArrowUpDown, Group, ChevronDown, Check, RefreshCw,
 } from 'lucide-react';
-import { api } from '../api/client';
+import { api, spoolbuddyApi } from '../api/client';
 import type { InventorySpool, SpoolAssignment } from '../api/client';
 import { Button } from '../components/Button';
 import { SpoolFormModal } from '../components/SpoolFormModal';
@@ -63,6 +63,7 @@ const DEFAULT_COLUMNS: ColumnConfig[] = [
   { id: 'stock', label: 'Stock', visible: false },
   { id: 'remaining', label: 'Remaining', visible: true },
   { id: 'cost_per_kg', label: 'Cost/kg', visible: false },
+  { id: 'weight_check', label: 'Weight Check', visible: false },
 ];
 
 function loadColumnConfig(): ColumnConfig[] {
@@ -126,6 +127,7 @@ type CellCtx = {
   currencySymbol: string;
   dateFormat: DateFormat;
   t: TFn;
+  onSyncWeight?: (spool: InventorySpool) => void;
 };
 
 // Column header labels (25 columns — matching SpoolBuddy exactly)
@@ -156,6 +158,7 @@ const columnHeaders: Record<string, (t: TFn) => string> = {
   stock: (t) => t('inventory.stock'),
   remaining: (t) => t('inventory.remaining'),
   cost_per_kg: (t) => t('inventory.costPerKg'),
+  weight_check: (t) => t('inventory.weightCheck'),
 };
 
 // Column cell renderers (25 columns — matching SpoolBuddy exactly)
@@ -285,6 +288,59 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
       {spool.cost_per_kg != null ? `${currencySymbol}${spool.cost_per_kg.toFixed(2)}` : '-'}
     </span>
   ),
+  weight_check: ({ spool, onSyncWeight }) => {
+    const scaleWeight = spool.last_scale_weight;
+    if (scaleWeight == null) return <span className="text-sm text-bambu-gray/50" title="No scale measurement">-</span>;
+
+    const coreWeight = spool.core_weight || 0;
+    const calculatedWeight = Math.max(0, spool.label_weight - spool.weight_used) + coreWeight;
+
+    // Edge case: scale < core_weight means spool is empty or not on scale — treat as match
+    let difference: number;
+    let isMatch: boolean;
+    if (scaleWeight < coreWeight) {
+      difference = scaleWeight - coreWeight;
+      isMatch = true;
+    } else {
+      difference = scaleWeight - calculatedWeight;
+      isMatch = Math.abs(difference) <= 50;
+    }
+
+    const diffStr = difference > 0 ? `+${Math.round(difference)}` : `${Math.round(difference)}`;
+    const tooltip = isMatch
+      ? `Scale: ${Math.round(scaleWeight)}g\nCalculated: ${Math.round(calculatedWeight)}g\nDifference: ${diffStr}g (within tolerance)`
+      : `Scale: ${Math.round(scaleWeight)}g\nCalculated: ${Math.round(calculatedWeight)}g\nDifference: ${diffStr}g (mismatch!)`;
+
+    return (
+      <div
+        className={`flex items-center gap-1 text-sm font-medium ${isMatch ? 'text-green-400' : 'text-yellow-400'}`}
+        title={tooltip}
+      >
+        <span>{Math.round(scaleWeight)}g</span>
+        {isMatch ? (
+          <Check className="w-3 h-3" />
+        ) : (
+          <>
+            <AlertTriangle className="w-3 h-3" />
+            {onSyncWeight && (
+              <button
+                type="button"
+                onClick={(e) => {
+                  e.stopPropagation();
+                  e.preventDefault();
+                  onSyncWeight(spool);
+                }}
+                className="p-1 hover:bg-bambu-green/20 rounded transition-colors text-bambu-green"
+                title="Sync: trust scale weight and reset tracking"
+              >
+                <RefreshCw className="w-3.5 h-3.5" />
+              </button>
+            )}
+          </>
+        )}
+      </div>
+    );
+  },
 };
 
 // Sort value extractors — return a comparable value for each sortable column
@@ -315,6 +371,11 @@ const columnSortValues: Record<string, (spool: InventorySpool, assignmentMap: Re
   tag_type: (s) => (s.tag_type || '').toLowerCase(),
   stock: (s) => s.slicer_filament ? 1 : 0,
   cost_per_kg: (s) => s.cost_per_kg ?? 0,
+  weight_check: (s) => {
+    if (s.last_scale_weight == null) return -1;
+    const expectedGross = Math.max(0, s.label_weight - s.weight_used) + s.core_weight;
+    return Math.abs(s.last_scale_weight - expectedGross);
+  },
 };
 
 const SORT_STATE_KEY = 'bambuddy-inventory-sort';
@@ -440,6 +501,18 @@ function InventoryPage() {
     },
   });
 
+  const handleSyncWeight = async (spool: InventorySpool) => {
+    if (spool.last_scale_weight == null) return;
+    try {
+      await spoolbuddyApi.updateSpoolWeight(spool.id, spool.last_scale_weight);
+      queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
+      const spoolName = [spool.brand, spool.material, spool.color_name].filter(Boolean).join(' ');
+      showToast(`Synced "${spoolName}" to scale weight`, 'success');
+    } catch {
+      showToast('Failed to sync weight', 'error');
+    }
+  };
+
   // Stats calculation (active spools only)
   const stats = useMemo(() => {
     if (!spools) return null;
@@ -1139,6 +1212,7 @@ function InventoryPage() {
                           currencySymbol={currencySymbol}
                           dateFormat={dateFormat}
                           t={t}
+                          onSyncWeight={handleSyncWeight}
                         />
                       );
                     }
@@ -1160,6 +1234,7 @@ function InventoryPage() {
                         currencySymbol={currencySymbol}
                         dateFormat={dateFormat}
                         t={t}
+                        onSyncWeight={handleSyncWeight}
                       />
                     );
                   })}
@@ -1436,7 +1511,7 @@ function SpoolCard({
 /* Single spool row for table view */
 function SpoolTableRow({
   spool, remaining, pct, onEdit, onRestore, onArchive, onDelete,
-  visibleColumns, assignmentMap, currencySymbol, dateFormat, t,
+  visibleColumns, assignmentMap, currencySymbol, dateFormat, t, onSyncWeight,
 }: {
   spool: InventorySpool;
   remaining: number;
@@ -1450,6 +1525,7 @@ function SpoolTableRow({
   currencySymbol: string;
   dateFormat: DateFormat;
   t: TFn;
+  onSyncWeight?: (spool: InventorySpool) => void;
 }) {
   return (
     <tr
@@ -1460,7 +1536,7 @@ function SpoolTableRow({
     >
       {visibleColumns.map((colId) => (
         <td key={colId} className="py-3 px-4">
-          {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, currencySymbol, dateFormat, t })}
+          {columnCells[colId]?.({ spool, remaining, pct, assignmentMap, currencySymbol, dateFormat, t, onSyncWeight })}
         </td>
       ))}
       <td className="py-3 px-4">
@@ -1490,7 +1566,7 @@ function SpoolTableRow({
 function SpoolTableGroup({
   spools, representative, remaining, pct, isExpanded, onToggle,
   onEdit, onArchive, onDelete,
-  visibleColumns, assignmentMap, currencySymbol, dateFormat, t,
+  visibleColumns, assignmentMap, currencySymbol, dateFormat, t, onSyncWeight,
 }: {
   spools: InventorySpool[];
   representative: InventorySpool;
@@ -1506,6 +1582,7 @@ function SpoolTableGroup({
   currencySymbol: string;
   dateFormat: DateFormat;
   t: TFn;
+  onSyncWeight?: (spool: InventorySpool) => void;
 }) {
   return (
     <>
@@ -1519,14 +1596,14 @@ function SpoolTableGroup({
             {idx === 0 ? (
               <div className="flex items-center gap-2">
                 <ChevronDown className={`w-4 h-4 text-bambu-gray transition-transform ${isExpanded ? '' : '-rotate-90'}`} />
-                {columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, currencySymbol, dateFormat, t })}
+                {columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, currencySymbol, dateFormat, t, onSyncWeight })}
               </div>
             ) : colId === 'id' ? (
               <span className="text-xs font-medium bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full">
                 {t('inventory.groupedSpools', { count: spools.length })}
               </span>
             ) : (
-              columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, currencySymbol, dateFormat, t })
+              columnCells[colId]?.({ spool: representative, remaining, pct, assignmentMap, currencySymbol, dateFormat, t, onSyncWeight })
             )}
           </td>
         ))}
@@ -1555,6 +1632,7 @@ function SpoolTableGroup({
             currencySymbol={currencySymbol}
             dateFormat={dateFormat}
             t={t}
+            onSyncWeight={onSyncWeight}
           />
         );
       })}

+ 6 - 22
frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx

@@ -3,7 +3,7 @@ import { useOutletContext, useNavigate } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
-import { api, spoolbuddyApi, type InventorySpool } from '../../api/client';
+import { api, type InventorySpool } from '../../api/client';
 import { SpoolIcon } from '../../components/spoolbuddy/SpoolIcon';
 import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';
 import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
@@ -177,26 +177,6 @@ export function SpoolBuddyDashboard() {
   }, [currentTagId, currentWeight, weightStable, displayedTagId, hiddenTagId]);
 
   // Auto-sync weight once when known spool first detected
-  const [weightUpdatedForSpool, setWeightUpdatedForSpool] = useState<number | null>(null);
-
-  useEffect(() => {
-    if (displayedSpool?.id !== weightUpdatedForSpool) {
-      setWeightUpdatedForSpool(null);
-    }
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [displayedSpool?.id]);
-
-  useEffect(() => {
-    if (displayedSpool && currentTagId && weightStable && weightUpdatedForSpool !== displayedSpool.id) {
-      setWeightUpdatedForSpool(displayedSpool.id);
-      const newWeight = currentWeight !== null ? Math.round(Math.max(0, currentWeight)) : null;
-      if (newWeight !== null) {
-        spoolbuddyApi.updateSpoolWeight(displayedSpool.id, newWeight)
-          .catch((err) => console.error('Failed to update spool weight:', err));
-      }
-    }
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [displayedSpool?.id, currentTagId, weightStable]);
 
   const handleCloseSpoolCard = () => {
     setHiddenTagId(displayedTagId);
@@ -205,7 +185,11 @@ export function SpoolBuddyDashboard() {
   const handleLinkTagToSpool = async (spool: InventorySpool) => {
     if (!displayedTagId) return;
     try {
-      await api.updateSpool(spool.id, { tag_uid: displayedTagId });
+      await api.linkTagToSpool(spool.id, {
+        tag_uid: displayedTagId,
+        tag_type: 'generic',
+        data_origin: 'nfc_link',
+      });
       setShowLinkModal(false);
       refetchSpools();
     } catch (e) {

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CWKU4EHC.js


+ 1 - 1
static/index.html

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

Some files were not shown because too many files changed in this diff