Bläddra i källkod

Add SpoolBuddy AMS slot config, external slots, and dashboard redesign

- AMS page: external spool slots (Ext/Ext-L/Ext-R), click-to-configure
  modal on all slots, temperature/humidity threshold-colored indicators,
  nozzle L/R badges for dual-nozzle printers, compact AMS-HT layout
- Dashboard: two-column layout with device status + printers list (left)
  and current spool card (right), state-colored scale/NFC icons, dashed
  border card styling
- Daemon: suppress redundant scale reports (±2g threshold + stability
  state change detection) to prevent weight display bouncing
- TopBar: auto-select online printers only, SpoolBuddy logo
maziggy 3 månader sedan
förälder
incheckning
46f209e2ac

+ 8 - 0
CHANGELOG.md

@@ -4,6 +4,14 @@ All notable changes to Bambuddy will be documented in this file.
 
 ## [0.2.2b1] - Unreleased
 
+### New Features
+- **SpoolBuddy AMS Page: External Slots & Slot Configuration** — The SpoolBuddy AMS page (`/spoolbuddy/ams`) now displays external spool slots (single nozzle: "Ext", dual nozzle: "Ext-L"/"Ext-R") and AMS-HT units in a compact horizontal row below the regular AMS grid, fitting within the 1024×600 kiosk display without scrolling. Clicking any AMS, AMS-HT, or external slot opens the `ConfigureAmsSlotModal` to configure filament type and color — the same modal used on the main Printers page. Dual-nozzle printers show L/R nozzle badges on each AMS unit. Temperature and humidity are displayed with threshold-colored SVG icons (green/gold/red) matching the Bambu Lab style on the main printer cards, using the configured AMS humidity and temperature thresholds from settings.
+- **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.
+
+### Improved
+- **SpoolBuddy Scale Value Stabilization** — The SpoolBuddy daemon now suppresses redundant scale weight reports: only sends updates when the weight changes by ≥2g or the stability state flips (stable ↔ unstable). Previously every 1-second report interval sent a reading regardless of change, causing the dashboard weight display to bounce continuously. The frontend also applies a 3g display threshold as defense-in-depth.
+- **SpoolBuddy TopBar: Online Printer Selection** — The printer selector in the SpoolBuddy top bar now only shows online printers and auto-selects the first online printer. If the currently selected printer goes offline, it automatically switches to the next available online printer. Also replaced the placeholder icon with the SpoolBuddy logo.
+
 ## [0.2.1] - 2026-02-27
 
 ### Fixed

BIN
frontend/public/img/spoolbuddy_logo_dark.png


BIN
frontend/public/spoolbuddy_logo_dark.png


+ 163 - 17
frontend/src/components/spoolbuddy/AmsUnitCard.tsx

@@ -15,24 +15,142 @@ function getAmsName(id: number): string {
   return `AMS ${id}`;
 }
 
-function formatHumidity(value: number | null): string {
-  if (value === null || value === undefined) return '-';
-  if (value > 5) return `${value}%`;
-  return `Level ${value}`;
+// --- SVG Icons (matching PrintersPage Bambu Lab style) ---
+
+function WaterDropEmpty({ className }: { className?: string }) {
+  return (
+    <svg className={className} viewBox="0 0 36 54" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <path d="M17.8131 0.00538C18.4463 -0.15091 20.3648 3.14642 20.8264 3.84781C25.4187 10.816 35.3089 26.9368 35.9383 34.8694C37.4182 53.5822 11.882 61.3357 2.53721 45.3789C-1.73471 38.0791 0.016 32.2049 3.178 25.0232C6.99221 16.3662 12.6411 7.90372 17.8131 0.00538ZM18.3738 7.24807L17.5881 7.48441C14.4452 12.9431 10.917 18.2341 8.19369 23.9368C4.6808 31.29 1.18317 38.5479 7.69403 45.5657C17.3058 55.9228 34.9847 46.8808 31.4604 32.8681C29.2558 24.0969 22.4207 15.2913 18.3776 7.24807H18.3738Z" fill="#C3C2C1"/>
+    </svg>
+  );
+}
+
+function WaterDropHalf({ className }: { className?: string }) {
+  return (
+    <svg className={className} viewBox="0 0 35 53" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <path d="M17.3165 0.0038C17.932 -0.14959 19.7971 3.08645 20.2458 3.77481C24.7103 10.6135 34.3251 26.4346 34.937 34.2198C36.3757 52.5848 11.5505 60.1942 2.46584 44.534C-1.68714 37.3735 0.0148 31.6085 3.08879 24.5603C6.79681 16.0605 12.2884 7.75907 17.3165 0.0038ZM17.8615 7.11561L17.0977 7.34755C14.0423 12.7048 10.6124 17.8974 7.96483 23.4941C4.54975 30.7107 1.14949 37.8337 7.47908 44.721C16.8233 54.8856 34.01 46.0117 30.5838 32.2595C28.4405 23.6512 21.7957 15.0093 17.8652 7.11561H17.8615Z" fill="#C3C2C1"/>
+      <path d="M5.03547 30.112C9.64453 30.4936 11.632 35.7985 16.4154 35.791C19.6339 35.7873 20.2161 33.2283 22.3853 31.6197C31.6776 24.7286 33.5835 37.4894 27.9881 44.4254C18.1878 56.5653 -1.16063 44.6013 5.03917 30.1158L5.03547 30.112Z" fill="#1F8FEB"/>
+    </svg>
+  );
 }
 
+function WaterDropFull({ className }: { className?: string }) {
+  return (
+    <svg className={className} viewBox="0 0 36 54" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <path d="M17.9625 4.48059L4.77216 26.3154L2.08228 40.2175L10.0224 50.8414H23.1594L33.3246 42.1693V30.2455L17.9625 4.48059Z" fill="#1F8FEB"/>
+      <path d="M17.7948 0.00538C18.4273 -0.15091 20.3438 3.14642 20.8048 3.84781C25.3921 10.816 35.2715 26.9368 35.9001 34.8694C37.3784 53.5822 11.8702 61.3357 2.53562 45.3789C-1.73163 38.0829 0.0134 32.2087 3.1757 25.027C6.98574 16.3662 12.6284 7.90372 17.7948 0.00538ZM18.3549 7.24807L17.57 7.48441C14.4306 12.9431 10.9063 18.2341 8.1859 23.9368C4.67686 31.29 1.18305 38.5479 7.68679 45.5657C17.2881 55.9228 34.9476 46.8808 31.4271 32.8681C29.2249 24.0969 22.3974 15.2913 18.3587 7.24807H18.3549Z" fill="#C3C2C1"/>
+    </svg>
+  );
+}
+
+function ThermometerEmpty({ className }: { className?: string }) {
+  return (
+    <svg className={className} viewBox="0 0 12 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <path d="M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z" stroke="#C3C2C1" strokeWidth="1" fill="none"/>
+      <circle cx="6" cy="15" r="2.5" stroke="#C3C2C1" strokeWidth="1" fill="none"/>
+    </svg>
+  );
+}
+
+function ThermometerHalf({ className }: { className?: string }) {
+  return (
+    <svg className={className} viewBox="0 0 12 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <rect x="4.5" y="8" width="3" height="4.5" fill="#d4a017" rx="0.5"/>
+      <circle cx="6" cy="15" r="2" fill="#d4a017"/>
+      <path d="M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z" stroke="#C3C2C1" strokeWidth="1" fill="none"/>
+    </svg>
+  );
+}
+
+function ThermometerFull({ className }: { className?: string }) {
+  return (
+    <svg className={className} viewBox="0 0 12 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+      <rect x="4.5" y="3" width="3" height="9.5" fill="#c62828" rx="0.5"/>
+      <circle cx="6" cy="15" r="2" fill="#c62828"/>
+      <path d="M6 0.5C4.6 0.5 3.5 1.6 3.5 3V12.1C2.6 12.8 2 13.9 2 15C2 17.2 3.8 19 6 19C8.2 19 10 17.2 10 15C10 13.9 9.4 12.8 8.5 12.1V3C8.5 1.6 7.4 0.5 6 0.5Z" stroke="#C3C2C1" strokeWidth="1" fill="none"/>
+    </svg>
+  );
+}
+
+// --- Threshold-colored indicators ---
+
+function HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60 }: { humidity: number; goodThreshold?: number; fairThreshold?: number }) {
+  let textColor: string;
+  let DropComponent: React.FC<{ className?: string }>;
+
+  if (humidity <= goodThreshold) {
+    textColor = '#22a352';
+    DropComponent = WaterDropEmpty;
+  } else if (humidity <= fairThreshold) {
+    textColor = '#d4a017';
+    DropComponent = WaterDropHalf;
+  } else {
+    textColor = '#c62828';
+    DropComponent = WaterDropFull;
+  }
+
+  return (
+    <div className="flex items-center gap-0.5">
+      <DropComponent className="w-2.5 h-3" />
+      <span className="font-medium tabular-nums text-[10px]" style={{ color: textColor }}>{humidity}%</span>
+    </div>
+  );
+}
+
+function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35 }: { temp: number; goodThreshold?: number; fairThreshold?: number }) {
+  let textColor: string;
+  let ThermoComponent: React.FC<{ className?: string }>;
+
+  if (temp <= goodThreshold) {
+    textColor = '#22a352';
+    ThermoComponent = ThermometerEmpty;
+  } else if (temp <= fairThreshold) {
+    textColor = '#d4a017';
+    ThermoComponent = ThermometerHalf;
+  } else {
+    textColor = '#c62828';
+    ThermoComponent = ThermometerFull;
+  }
+
+  return (
+    <div className="flex items-center gap-0.5">
+      <ThermoComponent className="w-2.5 h-3" />
+      <span className="font-medium tabular-nums text-[10px]" style={{ color: textColor }}>{temp}°C</span>
+    </div>
+  );
+}
+
+// --- Nozzle badge ---
+
+function NozzleBadge({ side }: { side: 'L' | 'R' }) {
+  return (
+    <span
+      className="inline-flex items-center justify-center w-3.5 h-3.5 text-[8px] font-bold rounded"
+      style={{ backgroundColor: '#1a4d2e', color: '#00ae42' }}
+    >
+      {side}
+    </span>
+  );
+}
+
+// --- Components ---
+
 interface SpoolSlotProps {
   tray: AMSTray;
   slotIndex: number;
   isActive: boolean;
+  onClick?: () => void;
 }
 
-function SpoolSlot({ tray, slotIndex, isActive }: SpoolSlotProps) {
+function SpoolSlot({ tray, slotIndex, isActive, onClick }: SpoolSlotProps) {
   const isEmpty = isTrayEmpty(tray);
   const color = trayColorToCSS(tray.tray_color);
 
   return (
-    <div className={`relative flex flex-col items-center p-2 rounded-lg transition-all ${isActive ? 'ring-2 ring-bambu-green' : ''}`}>
+    <div
+      className={`relative flex flex-col items-center p-2 rounded-lg transition-all ${isActive ? 'ring-2 ring-bambu-green' : ''} ${onClick ? 'cursor-pointer hover:bg-white/5' : ''}`}
+      onClick={onClick}
+    >
       {/* Spool visualization */}
       <div className="relative w-14 h-14 mb-1">
         {isEmpty ? (
@@ -77,12 +195,23 @@ function SpoolSlot({ tray, slotIndex, isActive }: SpoolSlotProps) {
   );
 }
 
+export interface AmsThresholds {
+  humidityGood: number;
+  humidityFair: number;
+  tempGood: number;
+  tempFair: number;
+}
+
 interface AmsUnitCardProps {
   unit: AMSUnit;
   activeSlot: number | null;
+  onConfigureSlot?: (amsId: number, trayId: number, tray: AMSTray | null) => void;
+  isDualNozzle?: boolean;
+  nozzleSide?: 'L' | 'R' | null;
+  thresholds?: AmsThresholds;
 }
 
-export function AmsUnitCard({ unit, activeSlot }: AmsUnitCardProps) {
+export function AmsUnitCard({ unit, activeSlot, onConfigureSlot, isDualNozzle, nozzleSide, thresholds }: AmsUnitCardProps) {
   const trays = unit.tray || [];
   const isHt = unit.is_ams_ht;
   const slotCount = isHt ? 1 : 4;
@@ -90,16 +219,29 @@ export function AmsUnitCard({ unit, activeSlot }: AmsUnitCardProps) {
   return (
     <div className="bg-bambu-dark-secondary rounded-lg p-3">
       {/* Header */}
-      <div className="flex items-center justify-between mb-3">
-        <span className="text-white font-medium">{getAmsName(unit.id)}</span>
-        {unit.humidity !== null && unit.humidity !== undefined && (
-          <div className="flex items-center gap-1 text-xs text-white/50">
-            <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
-              <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z" />
-            </svg>
-            <span>{formatHumidity(unit.humidity)}</span>
-          </div>
-        )}
+      <div className="flex items-center justify-between mb-2">
+        <div className="flex items-center gap-1.5">
+          <span className="text-white font-medium text-sm">{getAmsName(unit.id)}</span>
+          {isDualNozzle && nozzleSide && (
+            <NozzleBadge side={nozzleSide} />
+          )}
+        </div>
+        <div className="flex items-center gap-2">
+          {unit.temp != null && (
+            <TemperatureIndicator
+              temp={unit.temp}
+              goodThreshold={thresholds?.tempGood}
+              fairThreshold={thresholds?.tempFair}
+            />
+          )}
+          {unit.humidity != null && (
+            <HumidityIndicator
+              humidity={unit.humidity}
+              goodThreshold={thresholds?.humidityGood}
+              fairThreshold={thresholds?.humidityFair}
+            />
+          )}
+        </div>
       </div>
 
       {/* Slots grid */}
@@ -126,6 +268,7 @@ export function AmsUnitCard({ unit, activeSlot }: AmsUnitCardProps) {
               tray={tray}
               slotIndex={i}
               isActive={activeSlot === i}
+              onClick={onConfigureSlot ? () => onConfigureSlot(unit.id, i, isTrayEmpty(tray) ? null : tray) : undefined}
             />
           );
         })}
@@ -133,3 +276,6 @@ export function AmsUnitCard({ unit, activeSlot }: AmsUnitCardProps) {
     </div>
   );
 }
+
+// Exported for use in SpoolBuddyAmsPage compact cards
+export { HumidityIndicator, TemperatureIndicator, NozzleBadge };

+ 25 - 16
frontend/src/components/spoolbuddy/SpoolBuddyTopBar.tsx

@@ -1,5 +1,5 @@
-import { useState, useEffect } from 'react';
-import { useQuery } from '@tanstack/react-query';
+import { useState, useEffect, useMemo } from 'react';
+import { useQuery, useQueries } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { WifiOff } from 'lucide-react';
 import { api, type Printer } from '../../api/client';
@@ -19,12 +19,26 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
     queryFn: () => api.getPrinters(),
   });
 
-  // Auto-select first printer
+  // Fetch status for each printer to determine which are online
+  const statusQueries = useQueries({
+    queries: printers.map((printer: Printer) => ({
+      queryKey: ['printerStatus', printer.id],
+      queryFn: () => api.getPrinterStatus(printer.id),
+      refetchInterval: 10000,
+    })),
+  });
+
+  const onlinePrinters = useMemo(() => {
+    return printers.filter((_: Printer, i: number) => statusQueries[i]?.data?.connected);
+  }, [printers, statusQueries]);
+
+  // Auto-select first online printer
   useEffect(() => {
-    if (!selectedPrinterId && printers.length > 0) {
-      onPrinterChange(printers[0].id);
+    const currentStillOnline = onlinePrinters.some((p: Printer) => p.id === selectedPrinterId);
+    if ((!selectedPrinterId || !currentStillOnline) && onlinePrinters.length > 0) {
+      onPrinterChange(onlinePrinters[0].id);
     }
-  }, [printers, selectedPrinterId, onPrinterChange]);
+  }, [onlinePrinters, selectedPrinterId, onPrinterChange]);
 
   // Clock - update every second for kiosk display
   useEffect(() => {
@@ -38,13 +52,8 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
   return (
     <div className="h-11 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary flex items-center px-3 gap-4 shrink-0">
       {/* Logo */}
-      <div className="flex items-center gap-2 shrink-0">
-        <div className="w-6 h-6 rounded bg-bambu-green flex items-center justify-center">
-          <svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
-            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
-          </svg>
-        </div>
-        <span className="text-white font-semibold text-sm">SpoolBuddy</span>
+      <div className="flex items-center shrink-0">
+        <img src="/img/spoolbuddy_logo_dark.png" alt="SpoolBuddy" className="h-7" />
       </div>
 
       {/* Printer selector - centered */}
@@ -54,10 +63,10 @@ export function SpoolBuddyTopBar({ selectedPrinterId, onPrinterChange, deviceOnl
           onChange={(e) => onPrinterChange(Number(e.target.value))}
           className="bg-bambu-dark text-white text-sm px-3 py-1.5 rounded border border-bambu-dark-tertiary focus:outline-none focus:border-bambu-green min-w-[150px]"
         >
-          {printers.length === 0 ? (
-            <option value="">{t('spoolbuddy.status.noPrinters', 'No printers')}</option>
+          {onlinePrinters.length === 0 ? (
+            <option value="">{t('spoolbuddy.status.noPrinters', 'No printers online')}</option>
           ) : (
-            printers.map((printer: Printer) => (
+            onlinePrinters.map((printer: Printer) => (
               <option key={printer.id} value={printer.id}>
                 {printer.name}
               </option>

+ 288 - 16
frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx

@@ -1,12 +1,14 @@
-import { useEffect, useMemo } from 'react';
+import { useState, useEffect, useMemo, useRef } from 'react';
 import { useOutletContext } from 'react-router-dom';
-import { useQuery } from '@tanstack/react-query';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { Layers } from 'lucide-react';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
 import { api } from '../../api/client';
-import type { PrinterStatus } from '../../api/client';
-import { AmsUnitCard } from '../../components/spoolbuddy/AmsUnitCard';
+import type { PrinterStatus, AMSTray } from '../../api/client';
+import { AmsUnitCard, HumidityIndicator, TemperatureIndicator, NozzleBadge } from '../../components/spoolbuddy/AmsUnitCard';
+import type { AmsThresholds } from '../../components/spoolbuddy/AmsUnitCard';
+import { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal';
 
 function getAmsName(amsId: number): string {
   if (amsId <= 3) return `AMS ${String.fromCharCode(65 + amsId)}`;
@@ -14,9 +16,32 @@ function getAmsName(amsId: number): string {
   return `AMS ${amsId}`;
 }
 
+function mapModelCode(ssdpModel: string | null): string {
+  if (!ssdpModel) return '';
+  const modelMap: Record<string, string> = {
+    'O1D': 'H2D', 'O1E': 'H2D Pro', 'O2D': 'H2D Pro', 'O1C': 'H2C', 'O1S': 'H2S',
+    'BL-P001': 'X1C', 'BL-P002': 'X1', 'BL-P003': 'X1E',
+    'C11': 'P1S', 'C12': 'P1P', 'C13': 'P2S',
+    'N2S': 'A1', 'N1': 'A1 Mini',
+    'X1C': 'X1C', 'X1': 'X1', 'X1E': 'X1E', 'P1S': 'P1S', 'P1P': 'P1P', 'P2S': 'P2S',
+    'A1': 'A1', 'A1 Mini': 'A1 Mini', 'H2D': 'H2D', 'H2D Pro': 'H2D Pro', 'H2C': 'H2C', 'H2S': 'H2S',
+  };
+  return modelMap[ssdpModel] || ssdpModel;
+}
+
+function isTrayEmpty(tray: AMSTray): boolean {
+  return !tray.tray_type || tray.tray_type === '';
+}
+
+function trayColorToCSS(color: string | null): string {
+  if (!color) return '#808080';
+  return `#${color.slice(0, 6)}`;
+}
+
 export function SpoolBuddyAmsPage() {
   const { selectedPrinterId, setAlert } = useOutletContext<SpoolBuddyOutletContext>();
   const { t } = useTranslation();
+  const queryClient = useQueryClient();
 
   const { data: status } = useQuery<PrinterStatus>({
     queryKey: ['printerStatus', selectedPrinterId],
@@ -25,9 +50,73 @@ export function SpoolBuddyAmsPage() {
     staleTime: 30 * 1000,
   });
 
+  const { data: printer } = useQuery({
+    queryKey: ['printer', selectedPrinterId],
+    queryFn: () => api.getPrinter(selectedPrinterId!),
+    enabled: selectedPrinterId !== null,
+    staleTime: 60 * 1000,
+  });
+
+  const { data: slotPresets } = useQuery({
+    queryKey: ['slotPresets', selectedPrinterId],
+    queryFn: () => api.getSlotPresets(selectedPrinterId!),
+    enabled: selectedPrinterId !== null,
+    staleTime: 2 * 60 * 1000,
+  });
+
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: () => api.getSettings(),
+    staleTime: 5 * 60 * 1000,
+  });
+
   const isConnected = status?.connected ?? false;
   const amsUnits = useMemo(() => status?.ams ?? [], [status?.ams]);
+  const regularAms = useMemo(() => amsUnits.filter(u => !u.is_ams_ht), [amsUnits]);
+  const htAms = useMemo(() => amsUnits.filter(u => u.is_ams_ht), [amsUnits]);
   const trayNow = status?.tray_now ?? 255;
+  const isDualNozzle = printer?.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
+  const vtTrays = useMemo(() => [...(status?.vt_tray ?? [])].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)), [status?.vt_tray]);
+
+  const amsThresholds: AmsThresholds | undefined = settings ? {
+    humidityGood: Number(settings.ams_humidity_good) || 40,
+    humidityFair: Number(settings.ams_humidity_fair) || 60,
+    tempGood: Number(settings.ams_temp_good) || 28,
+    tempFair: Number(settings.ams_temp_fair) || 35,
+  } : undefined;
+
+  // Cache ams_extruder_map to prevent L/R indicators bouncing on updates
+  const cachedAmsExtruderMap = useRef<Record<string, number>>({});
+  useEffect(() => {
+    if (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0) {
+      cachedAmsExtruderMap.current = status.ams_extruder_map;
+    }
+  }, [status?.ams_extruder_map]);
+  const amsExtruderMap = (status?.ams_extruder_map && Object.keys(status.ams_extruder_map).length > 0)
+    ? status.ams_extruder_map
+    : cachedAmsExtruderMap.current;
+
+  const getNozzleSide = (amsId: number): 'L' | 'R' | null => {
+    if (!isDualNozzle) return null;
+    const mappedExtruderId = amsExtruderMap[String(amsId)];
+    const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
+    const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
+    // extruder 0 = right, 1 = left
+    return extruderId === 1 ? 'L' : 'R';
+  };
+
+  const [configureSlotModal, setConfigureSlotModal] = useState<{
+    amsId: number;
+    trayId: number;
+    trayCount: number;
+    trayType?: string;
+    trayColor?: string;
+    traySubBrands?: string;
+    trayInfoIdx?: string;
+    extruderId?: number;
+    caliIdx?: number | null;
+    savedPresetId?: string;
+  } | null>(null);
 
   const getActiveSlotForAms = (amsId: number): number | null => {
     if (trayNow === 255 || trayNow === 254) return null;
@@ -35,7 +124,6 @@ export function SpoolBuddyAmsPage() {
       const activeAmsId = Math.floor(trayNow / 4);
       if (activeAmsId === amsId) return trayNow % 4;
     }
-    // AMS-HT: tray_now 16-23 maps to AMS-HT 128-135
     if (amsId >= 128 && amsId <= 135) {
       const htIndex = amsId - 128;
       if (trayNow === 16 + htIndex) return 0;
@@ -43,6 +131,44 @@ export function SpoolBuddyAmsPage() {
     return null;
   };
 
+  const handleAmsSlotClick = (amsId: number, trayId: number, tray: AMSTray | null) => {
+    const globalTrayId = amsId >= 128 ? (amsId - 128) * 4 + trayId + 64 : amsId * 4 + trayId;
+    const slotPreset = slotPresets?.[globalTrayId];
+    const mappedExtruderId = amsExtruderMap[String(amsId)];
+    const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
+    const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
+    setConfigureSlotModal({
+      amsId,
+      trayId,
+      trayCount: tray ? (amsId >= 128 ? 1 : 4) : 4,
+      trayType: tray?.tray_type || undefined,
+      trayColor: tray?.tray_color || undefined,
+      traySubBrands: tray?.tray_sub_brands || undefined,
+      trayInfoIdx: tray?.tray_info_idx || undefined,
+      extruderId: isDualNozzle ? extruderId : undefined,
+      caliIdx: tray?.cali_idx,
+      savedPresetId: slotPreset?.preset_id,
+    });
+  };
+
+  const handleExtSlotClick = (extTray: AMSTray) => {
+    const extTrayId = extTray.id ?? 254;
+    const slotTrayId = extTrayId - 254;
+    const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
+    setConfigureSlotModal({
+      amsId: 255,
+      trayId: slotTrayId,
+      trayCount: 1,
+      trayType: extTray.tray_type || undefined,
+      trayColor: extTray.tray_color || undefined,
+      traySubBrands: extTray.tray_sub_brands || undefined,
+      trayInfoIdx: extTray.tray_info_idx || undefined,
+      extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,
+      caliIdx: extTray.cali_idx,
+      savedPresetId: extSlotPreset?.preset_id,
+    });
+  };
+
   // Set alert for low filament in status bar
   useEffect(() => {
     if (!isConnected && selectedPrinterId) {
@@ -63,9 +189,58 @@ export function SpoolBuddyAmsPage() {
     setAlert(null);
   }, [amsUnits, isConnected, selectedPrinterId, setAlert, t]);
 
+  // Build list of single-slot items (AMS-HT + External) for compact rendering
+  const singleSlots = useMemo(() => {
+    const items: {
+      key: string; label: string; tray: AMSTray; isEmpty: boolean; isActive: boolean;
+      temp?: number | null; humidity?: number | null; nozzleSide?: 'L' | 'R' | null;
+      onClick: () => void;
+    }[] = [];
+
+    for (const unit of htAms) {
+      const tray = unit.tray?.[0] || {
+        id: 0, tray_color: null, tray_type: '', tray_sub_brands: null,
+        tray_id_name: null, tray_info_idx: null, remain: -1, k: null,
+        cali_idx: null, tag_uid: null, tray_uuid: null, nozzle_temp_min: null, nozzle_temp_max: null,
+      };
+      items.push({
+        key: `ht-${unit.id}`,
+        label: getAmsName(unit.id),
+        tray,
+        isEmpty: isTrayEmpty(tray),
+        isActive: getActiveSlotForAms(unit.id) === 0,
+        temp: unit.temp,
+        humidity: unit.humidity,
+        nozzleSide: getNozzleSide(unit.id),
+        onClick: () => handleAmsSlotClick(unit.id, 0, isTrayEmpty(tray) ? null : tray),
+      });
+    }
+
+    for (const extTray of vtTrays) {
+      const extTrayId = extTray.id ?? 254;
+      const isExtActive = isDualNozzle && trayNow === 254
+        ? (extTrayId === 254 && status?.active_extruder === 1) ||
+          (extTrayId === 255 && status?.active_extruder === 0)
+        : trayNow === extTrayId;
+      items.push({
+        key: `ext-${extTrayId}`,
+        label: isDualNozzle
+          ? (extTrayId === 254 ? t('printers.extL', 'Ext-L') : t('printers.extR', 'Ext-R'))
+          : t('printers.ext', 'Ext'),
+        tray: extTray,
+        isEmpty: isTrayEmpty(extTray),
+        isActive: isExtActive,
+        nozzleSide: isDualNozzle ? (extTrayId === 254 ? 'L' : 'R') : null,
+        onClick: () => handleExtSlotClick(extTray),
+      });
+    }
+
+    return items;
+  }, [htAms, vtTrays, isDualNozzle, trayNow, status?.active_extruder, slotPresets, amsExtruderMap, t]);
+
   return (
-    <div className="h-full flex flex-col p-4">
-      <div className="flex-1 min-h-0 overflow-y-auto">
+    <div className="h-full flex flex-col p-3">
+      <div className="flex-1 min-h-0">
         {!selectedPrinterId ? (
           <div className="flex-1 flex items-center justify-center h-full">
             <div className="text-center text-white/50">
@@ -79,7 +254,7 @@ export function SpoolBuddyAmsPage() {
               <p className="text-lg mb-2">{t('spoolbuddy.ams.printerDisconnected', 'Printer disconnected')}</p>
             </div>
           </div>
-        ) : amsUnits.length === 0 ? (
+        ) : amsUnits.length === 0 && vtTrays.length === 0 ? (
           <div className="flex-1 flex items-center justify-center h-full">
             <div className="text-center text-white/50">
               <Layers className="w-12 h-12 mx-auto mb-3 opacity-50" />
@@ -88,17 +263,114 @@ export function SpoolBuddyAmsPage() {
             </div>
           </div>
         ) : (
-          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
-            {amsUnits.map((unit) => (
-              <AmsUnitCard
-                key={unit.id}
-                unit={unit}
-                activeSlot={getActiveSlotForAms(unit.id)}
-              />
-            ))}
+          <div className="flex flex-col gap-3 h-full">
+            {/* Regular AMS cards — 4-slot, 2-col grid */}
+            <div className="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1 min-h-0">
+              {regularAms.map((unit) => (
+                <AmsUnitCard
+                  key={unit.id}
+                  unit={unit}
+                  activeSlot={getActiveSlotForAms(unit.id)}
+                  onConfigureSlot={handleAmsSlotClick}
+                  isDualNozzle={isDualNozzle}
+                  nozzleSide={getNozzleSide(unit.id)}
+                  thresholds={amsThresholds}
+                />
+              ))}
+            </div>
+
+            {/* Third row: single-slot cards (AMS-HT + External) */}
+            {singleSlots.length > 0 && (
+              <div className="flex gap-2 flex-shrink-0">
+                {singleSlots.map(({ key, label, tray, isEmpty, isActive, temp, humidity, nozzleSide, onClick }) => {
+                  const color = trayColorToCSS(tray.tray_color);
+                  return (
+                    <div
+                      key={key}
+                      className={`bg-bambu-dark-secondary rounded-lg px-2 py-1.5 cursor-pointer hover:bg-bambu-dark-secondary/80 transition-all flex items-center gap-2 ${isActive ? 'ring-2 ring-bambu-green' : ''}`}
+                      onClick={onClick}
+                    >
+                      {/* Spool */}
+                      <div className="relative w-8 h-8 flex-shrink-0">
+                        {isEmpty ? (
+                          <div className="w-full h-full rounded-full border-2 border-dashed border-gray-500 flex items-center justify-center">
+                            <div className="w-1.5 h-1.5 rounded-full bg-gray-600" />
+                          </div>
+                        ) : (
+                          <svg viewBox="0 0 56 56" className="w-full h-full">
+                            <circle cx="28" cy="28" r="26" fill={color} />
+                            <circle cx="28" cy="28" r="20" fill={color} style={{ filter: 'brightness(0.85)' }} />
+                            <ellipse cx="20" cy="20" rx="6" ry="4" fill="white" opacity="0.3" />
+                            <circle cx="28" cy="28" r="8" fill="#2d2d2d" />
+                            <circle cx="28" cy="28" r="5" fill="#1a1a1a" />
+                          </svg>
+                        )}
+                        {isActive && (
+                          <div className="absolute -bottom-0.5 left-1/2 -translate-x-1/2 w-1.5 h-1.5 bg-bambu-green rounded-full" />
+                        )}
+                      </div>
+                      {/* Info */}
+                      <div className="min-w-0">
+                        <div className="flex items-center gap-1">
+                          <span className="text-[10px] text-white/50 font-medium truncate">{label}</span>
+                          {nozzleSide && <NozzleBadge side={nozzleSide} />}
+                        </div>
+                        <div className="text-xs text-white/80 truncate">
+                          {isEmpty ? 'Empty' : tray.tray_type || '?'}
+                        </div>
+                        {(temp != null || humidity != null) && (
+                          <div className="flex items-center gap-1.5">
+                            {temp != null && (
+                              <TemperatureIndicator
+                                temp={temp}
+                                goodThreshold={amsThresholds?.tempGood}
+                                fairThreshold={amsThresholds?.tempFair}
+                              />
+                            )}
+                            {humidity != null && (
+                              <HumidityIndicator
+                                humidity={humidity}
+                                goodThreshold={amsThresholds?.humidityGood}
+                                fairThreshold={amsThresholds?.humidityFair}
+                              />
+                            )}
+                          </div>
+                        )}
+                      </div>
+                      {/* Fill bar */}
+                      {!isEmpty && tray.remain != null && tray.remain >= 0 && (
+                        <div className="w-1 h-6 bg-bambu-dark-tertiary rounded-full overflow-hidden flex-shrink-0 flex flex-col-reverse">
+                          <div
+                            className="w-full rounded-full"
+                            style={{
+                              height: `${tray.remain}%`,
+                              backgroundColor: tray.remain > 50 ? '#22c55e' : tray.remain > 20 ? '#f59e0b' : '#ef4444',
+                            }}
+                          />
+                        </div>
+                      )}
+                    </div>
+                  );
+                })}
+              </div>
+            )}
           </div>
         )}
       </div>
+
+      {configureSlotModal && selectedPrinterId && (
+        <ConfigureAmsSlotModal
+          isOpen={!!configureSlotModal}
+          onClose={() => setConfigureSlotModal(null)}
+          printerId={selectedPrinterId}
+          slotInfo={configureSlotModal}
+          printerModel={mapModelCode(printer?.model ?? null) || undefined}
+          onSuccess={() => {
+            queryClient.invalidateQueries({ queryKey: ['slotPresets', selectedPrinterId] });
+            queryClient.invalidateQueries({ queryKey: ['printerStatus', selectedPrinterId] });
+          }}
+        />
+      )}
     </div>
   );
 }

+ 147 - 76
frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx

@@ -1,9 +1,9 @@
-import { useState, useEffect, useMemo } from 'react';
+import { useState, useEffect, useMemo, useRef } from 'react';
 import { useOutletContext } 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, spoolbuddyApi, type InventorySpool, type PrinterStatus } from '../../api/client';
 import { SpoolIcon } from '../../components/spoolbuddy/SpoolIcon';
 import { SpoolInfoCard, UnknownTagCard } from '../../components/spoolbuddy/SpoolInfoCard';
 import { LinkSpoolModal } from '../../components/spoolbuddy/LinkSpoolModal';
@@ -90,7 +90,7 @@ function DeviceOfflineState() {
           {t('spoolbuddy.status.deviceOffline', 'Device Offline')}
         </p>
         <p className="text-sm text-zinc-600">
-          {t('spoolbuddy.status.waitingConnection', 'Waiting for device connection...')}
+          {t('spoolbuddy.status.connectDisplay', 'Connect the SpoolBuddy display to scan spools')}
         </p>
       </div>
 
@@ -105,8 +105,29 @@ function DeviceOfflineState() {
 }
 
 // --- Main Dashboard ---
+// Helper to get printer status label
+function getPrinterStateLabel(state: string | null, connected: boolean): string {
+  if (!connected) return 'Offline';
+  if (!state || state === 'IDLE') return 'Idle';
+  if (state === 'RUNNING') return 'Printing';
+  if (state === 'PAUSE') return 'Paused';
+  if (state === 'FINISH') return 'Finished';
+  if (state === 'FAILED') return 'Failed';
+  return state;
+}
+
+function getPrinterStateColor(state: string | null, connected: boolean): string {
+  if (!connected) return 'bg-zinc-500';
+  if (!state || state === 'IDLE') return 'bg-bambu-green';
+  if (state === 'RUNNING') return 'bg-bambu-green animate-pulse';
+  if (state === 'PAUSE') return 'bg-amber-500';
+  if (state === 'FINISH') return 'bg-bambu-green';
+  if (state === 'FAILED') return 'bg-red-500';
+  return 'bg-zinc-500';
+}
+
 export function SpoolBuddyDashboard() {
-  const { sbState } = useOutletContext<SpoolBuddyOutletContext>();
+  const { sbState, selectedPrinterId, setSelectedPrinterId } = useOutletContext<SpoolBuddyOutletContext>();
   const { t } = useTranslation();
 
   // Fetch spools for stats, tag lookup, and untagged list
@@ -115,6 +136,31 @@ export function SpoolBuddyDashboard() {
     queryFn: () => api.getSpools(false),
   });
 
+  // Fetch printers list
+  const { data: printers = [] } = useQuery({
+    queryKey: ['printers'],
+    queryFn: () => api.getPrinters(),
+    staleTime: 30 * 1000,
+  });
+
+  // Fetch status for each printer
+  const printerStatuses = useQuery({
+    queryKey: ['printerStatuses', printers.map(p => p.id).join(',')],
+    queryFn: async () => {
+      const statuses: Record<number, PrinterStatus> = {};
+      await Promise.all(
+        printers.map(async (p) => {
+          try {
+            statuses[p.id] = await api.getPrinterStatus(p.id);
+          } catch { /* ignore */ }
+        })
+      );
+      return statuses;
+    },
+    enabled: printers.length > 0,
+    staleTime: 30 * 1000,
+  });
+
   // Current Spool card state - persists until user closes or new tag detected
   const [displayedTagId, setDisplayedTagId] = useState<string | null>(null);
   const [displayedWeight, setDisplayedWeight] = useState<number | null>(null);
@@ -126,6 +172,16 @@ export function SpoolBuddyDashboard() {
   const currentWeight = sbState.weight;
   const weightStable = sbState.weightStable;
 
+  // Stabilized scale display: only update when change exceeds threshold to prevent bouncing
+  const stableDisplayWeight = useRef<number | null>(null);
+  const WEIGHT_THRESHOLD = 3; // grams - ignore changes smaller than this
+  if (currentWeight === null) {
+    stableDisplayWeight.current = null;
+  } else if (stableDisplayWeight.current === null || Math.abs(currentWeight - stableDisplayWeight.current) >= WEIGHT_THRESHOLD || weightStable) {
+    stableDisplayWeight.current = currentWeight;
+  }
+  const scaleDisplayValue = stableDisplayWeight.current;
+
   // Find spool by tag_id in the loaded spools list
   const displayedSpool = useMemo(() => {
     if (!displayedTagId) return null;
@@ -215,10 +271,12 @@ export function SpoolBuddyDashboard() {
   const materials = new Set(spools.map((s) => s.material)).size;
   const brands = new Set(spools.filter((s) => s.brand).map((s) => s.brand)).size;
 
+  const statuses = printerStatuses.data ?? {};
+
   return (
     <div className="h-full flex flex-col p-4">
       {/* Compact stats bar */}
-      <div className="flex items-center gap-6 px-4 py-2 bg-zinc-800 rounded-xl border border-zinc-700 mb-4 shrink-0">
+      <div className="flex items-center gap-6 px-4 py-2 bg-zinc-800/50 rounded-xl border border-zinc-700/50 mb-4 shrink-0">
         <div className="flex items-center gap-2">
           <span className="text-2xl font-bold text-zinc-100">{totalSpools}</span>
           <span className="text-sm text-zinc-500">{t('spoolbuddy.inventory.spools', 'Spools')}</span>
@@ -235,46 +293,42 @@ export function SpoolBuddyDashboard() {
         </div>
       </div>
 
-      {/* Main content: Device status (left) + Hero spool card (right) */}
+      {/* Main content: Device + Printers (left) + Current Spool (right) */}
       <div className="flex-1 flex gap-4 min-h-0">
-        {/* Left column: Device Status */}
-        <div className="w-1/3 flex flex-col gap-3">
-          <div className="bg-zinc-800 rounded-lg p-4">
+        {/* Left column */}
+        <div className="w-5/12 flex flex-col gap-4 min-h-0">
+          {/* Device card */}
+          <div className="border border-dashed border-zinc-700/50 rounded-xl p-4 shrink-0">
             <h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-wide mb-3">
               {t('spoolbuddy.dashboard.device', 'Device')}
             </h2>
 
-            <div className="space-y-3">
+            <div className="space-y-2.5">
               {/* Connection status */}
               <div className="flex items-center gap-3">
                 <div className={`w-2.5 h-2.5 rounded-full ${sbState.deviceOnline ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`} />
                 <span className="text-sm text-zinc-400">
-                  {sbState.deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Offline')}
+                  {sbState.deviceOnline ? t('spoolbuddy.status.online', 'Online') : t('spoolbuddy.status.offline', 'Disconnected')}
                 </span>
               </div>
 
               {/* Scale weight */}
-              <div className="flex items-center justify-between p-3 bg-zinc-900 rounded-lg">
+              <div className="flex items-center justify-between p-3 bg-zinc-800/50 rounded-lg">
                 <div className="flex items-center gap-2">
-                  <svg className="w-4 h-4 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                  <svg className={`w-4 h-4 ${sbState.deviceOnline ? 'text-green-500' : 'text-zinc-500'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
                     <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
                   </svg>
                   <span className="text-xs text-zinc-500">{t('spoolbuddy.spool.scaleWeight', 'Scale')}</span>
                 </div>
-                <div className="flex items-center gap-2">
-                  <span className="text-lg font-mono font-semibold text-zinc-100">
-                    {currentWeight !== null ? `${Math.abs(currentWeight) <= 20 ? 0 : Math.round(Math.max(0, currentWeight))}g` : '\u2014'}
-                  </span>
-                  {weightStable && currentWeight !== null && (
-                    <span className="w-2 h-2 rounded-full bg-green-500" title={t('spoolbuddy.weight.stable', 'Stable')} />
-                  )}
-                </div>
+                <span className="text-lg font-mono font-semibold text-zinc-100">
+                  {scaleDisplayValue !== null ? `${Math.abs(scaleDisplayValue) <= 20 ? 0 : Math.round(Math.max(0, scaleDisplayValue))}g` : '\u2014'}
+                </span>
               </div>
 
               {/* NFC status */}
-              <div className="flex items-center justify-between p-3 bg-zinc-900 rounded-lg">
+              <div className="flex items-center justify-between p-3 bg-zinc-800/50 rounded-lg">
                 <div className="flex items-center gap-2">
-                  <svg className="w-4 h-4 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                  <svg className={`w-4 h-4 ${sbState.deviceOnline ? 'text-green-500' : 'text-zinc-500'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
                     <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
                   </svg>
                   <span className="text-xs text-zinc-500">NFC</span>
@@ -286,63 +340,80 @@ export function SpoolBuddyDashboard() {
             </div>
           </div>
 
-          {/* Weight display */}
-          <div className="bg-zinc-800 rounded-lg p-4 flex flex-col items-center">
-            <span className="text-4xl font-light tabular-nums text-zinc-100">
-              {currentWeight !== null ? currentWeight.toFixed(1) : '--.-'}
-            </span>
-            <span className="text-lg text-zinc-500">g</span>
-            <div className="flex items-center gap-2 mt-2">
-              <div className={`w-2 h-2 rounded-full ${
-                !sbState.deviceOnline
-                  ? 'bg-zinc-600'
-                  : weightStable
-                  ? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.5)]'
-                  : 'bg-amber-500 animate-pulse'
-              }`} />
-              <span className="text-xs text-zinc-400">
-                {!sbState.deviceOnline
-                  ? t('spoolbuddy.weight.noReading', 'No reading')
-                  : weightStable
-                  ? t('spoolbuddy.weight.stable', 'Stable')
-                  : t('spoolbuddy.weight.measuring', 'Measuring...')}
-              </span>
+          {/* Printers card */}
+          <div className="border border-dashed border-zinc-700/50 rounded-xl p-4 flex-1 min-h-0 flex flex-col">
+            <h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-wide mb-2">
+              {t('spoolbuddy.dashboard.printers', 'Printers')}
+            </h2>
+            <div className="space-y-1.5 overflow-y-auto flex-1 min-h-0">
+              {printers.length === 0 ? (
+                <p className="text-sm text-zinc-600">{t('spoolbuddy.dashboard.noPrinters', 'No printers configured')}</p>
+              ) : (
+                printers.map((p) => {
+                  const st = statuses[p.id];
+                  const stateLabel = getPrinterStateLabel(st?.state ?? null, st?.connected ?? false);
+                  const stateColor = getPrinterStateColor(st?.state ?? null, st?.connected ?? false);
+                  const isSelected = selectedPrinterId === p.id;
+                  return (
+                    <button
+                      key={p.id}
+                      onClick={() => setSelectedPrinterId(p.id)}
+                      className={`w-full text-left py-1.5 px-3 bg-zinc-800/50 rounded-lg border-l-2 transition-colors hover:bg-zinc-800 ${
+                        isSelected ? 'border-l-bambu-green' : 'border-l-bambu-green/40'
+                      }`}
+                    >
+                      <div className="flex items-center justify-between">
+                        <span className="text-sm font-medium text-zinc-200">{p.name}</span>
+                        <div className="flex items-center gap-1.5">
+                          <div className={`w-1.5 h-1.5 rounded-full ${stateColor}`} />
+                          <span className="text-xs text-zinc-500">{stateLabel}</span>
+                        </div>
+                      </div>
+                    </button>
+                  );
+                })
+              )}
             </div>
           </div>
         </div>
 
-        {/* Right column: Hero Spool Card */}
-        <div className="w-2/3">
-          <div className="bg-zinc-800 rounded-lg p-6 h-full flex items-center justify-center">
-            {showCard && isMatchedSpool && displayedSpool ? (
-              <SpoolInfoCard
-                spool={{
-                  id: displayedSpool.id,
-                  tag_uid: displayedTagId!,
-                  material: displayedSpool.material,
-                  subtype: displayedSpool.subtype,
-                  color_name: displayedSpool.color_name,
-                  rgba: displayedSpool.rgba,
-                  brand: displayedSpool.brand,
-                  label_weight: displayedSpool.label_weight,
-                  core_weight: displayedSpool.core_weight,
-                  weight_used: displayedSpool.weight_used,
-                }}
-                scaleWeight={liveWeight}
-                weightStable={weightStable}
-                onClose={handleCloseSpoolCard}
-                onSyncWeight={() => refetchSpools()}
-              />
-            ) : showCard && isUnknownTag ? (
-              <UnknownTagCard
-                tagUid={displayedTagId!}
-                scaleWeight={liveWeight ?? (displayedWeight !== null ? displayedWeight : null)}
-                onLinkSpool={untaggedSpools.length > 0 ? () => setShowLinkModal(true) : undefined}
-                onClose={handleCloseSpoolCard}
-              />
-            ) : (
-              sbState.deviceOnline ? <ColorCyclingSpool /> : <DeviceOfflineState />
-            )}
+        {/* Right column: Current Spool */}
+        <div className="w-7/12 min-h-0">
+          <div className="border border-dashed border-zinc-700/50 rounded-xl p-6 h-full flex flex-col">
+            <h2 className="text-xs font-semibold text-zinc-400 uppercase tracking-wide mb-4 shrink-0">
+              {t('spoolbuddy.dashboard.currentSpool', 'Current Spool')}
+            </h2>
+            <div className="flex-1 flex items-center justify-center min-h-0">
+              {showCard && isMatchedSpool && displayedSpool ? (
+                <SpoolInfoCard
+                  spool={{
+                    id: displayedSpool.id,
+                    tag_uid: displayedTagId!,
+                    material: displayedSpool.material,
+                    subtype: displayedSpool.subtype,
+                    color_name: displayedSpool.color_name,
+                    rgba: displayedSpool.rgba,
+                    brand: displayedSpool.brand,
+                    label_weight: displayedSpool.label_weight,
+                    core_weight: displayedSpool.core_weight,
+                    weight_used: displayedSpool.weight_used,
+                  }}
+                  scaleWeight={liveWeight}
+                  weightStable={weightStable}
+                  onClose={handleCloseSpoolCard}
+                  onSyncWeight={() => refetchSpools()}
+                />
+              ) : showCard && isUnknownTag ? (
+                <UnknownTagCard
+                  tagUid={displayedTagId!}
+                  scaleWeight={liveWeight ?? (displayedWeight !== null ? displayedWeight : null)}
+                  onLinkSpool={untaggedSpools.length > 0 ? () => setShowLinkModal(true) : undefined}
+                  onClose={handleCloseSpoolCard}
+                />
+              ) : (
+                sbState.deviceOnline ? <ColorCyclingSpool /> : <DeviceOfflineState />
+              )}
+            </div>
           </div>
         </div>
       </div>

+ 1 - 0
frontend/vite.config.ts

@@ -14,6 +14,7 @@ export default defineConfig({
     chunkSizeWarningLimit: 3000,
   },
   server: {
+    host: '0.0.0.0',
     proxy: {
       '/api/v1/ws': {
         target: backendUrl,

+ 16 - 6
spoolbuddy/daemon/main.py

@@ -73,6 +73,9 @@ async def scale_poll_loop(config: Config, api: APIClient):
         return
 
     last_report = 0.0
+    last_reported_grams: float | None = None
+    last_reported_stable: bool | None = None
+    REPORT_THRESHOLD = 2.0  # Only report if weight changed by more than this (grams)
     try:
         while True:
             result = await asyncio.to_thread(scale.read)
@@ -82,12 +85,19 @@ async def scale_poll_loop(config: Config, api: APIClient):
                 now = time.monotonic()
 
                 if now - last_report >= config.scale_report_interval:
-                    await api.scale_reading(
-                        device_id=config.device_id,
-                        weight_grams=grams,
-                        stable=stable,
-                        raw_adc=raw_adc,
-                    )
+                    # Only send when weight changed meaningfully or stability flipped
+                    weight_changed = last_reported_grams is None or abs(grams - last_reported_grams) >= REPORT_THRESHOLD
+                    stability_changed = last_reported_stable is None or stable != last_reported_stable
+
+                    if weight_changed or stability_changed:
+                        await api.scale_reading(
+                            device_id=config.device_id,
+                            weight_grams=grams,
+                            stable=stable,
+                            raw_adc=raw_adc,
+                        )
+                        last_reported_grams = grams
+                        last_reported_stable = stable
                     last_report = now
 
             await asyncio.sleep(config.scale_read_interval)

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 1 - 0
static/assets/index-C7NPsWbv.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 1 - 0
static/assets/index-CxFtC4Kb.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/index-DAOWLKX-.css


BIN
static/img/spoolbuddy_logo_dark.png


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CxFtC4Kb.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DJjXosw8.css">
+    <script type="module" crossorigin src="/assets/index-C7NPsWbv.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DAOWLKX-.css">
   </head>
   <body>
     <div id="root"></div>

BIN
static/spoolbuddy_logo_dark.png


Vissa filer visades inte eftersom för många filer har ändrats