Browse Source

Add AMS humidity/temperature indicators with configurable thresholds

  - Add dynamic humidity indicator with Bambu Lab style water drop icons
    - Empty drop for good (dry), half-filled for fair, full for bad (wet)
    - Configurable thresholds via Settings page

  - Add dynamic temperature indicator with thermometer icons
    - Empty for good, half-filled for fair, full for hot
    - Colors match humidity: green/gold/red

  - Add AMS Display Thresholds settings card
    - Humidity thresholds (good/fair) configurable
    - Temperature thresholds (good/fair) configurable
    - Persisted in backend settings

  - Fix AMS card stability issues
    - Add ams_extruder_map to WebSocket broadcasts
    - Cache AMS data and extruder map to prevent bouncing
    - Fix L/R nozzle indicator for dual-nozzle H2 printers

  - Fix status summary bar counting
    - Don't count printers with unknown status as offline

  - Theme-aware L/R nozzle badges (light/dark theme support)
maziggy 5 months ago
parent
commit
71e15b0ed7

+ 6 - 0
README.md

@@ -22,6 +22,12 @@ v∆v
 ## Features
 ## Features
 
 
 - **Multi-Printer Support** - Connect and monitor multiple Bambu Lab printers (H2C, H2D, H2S, X1, X1C, P1P, P1S, A1, A1 Mini)
 - **Multi-Printer Support** - Connect and monitor multiple Bambu Lab printers (H2C, H2D, H2S, X1, X1C, P1P, P1S, A1, A1 Mini)
+- **AMS Humidity & Temperature Monitoring** - Visual indicators for AMS dryness conditions:
+  - Dynamic water drop icons (empty/half/full) based on humidity level
+  - Dynamic thermometer icons showing temperature status
+  - Color-coded values: green (good), gold (fair), red (bad)
+  - Configurable thresholds in Settings
+  - Dual-nozzle support with L/R indicators for H2 series
 - **Automatic Print Archiving** - Automatically saves 3MF files with full metadata extraction
 - **Automatic Print Archiving** - Automatically saves 3MF files with full metadata extraction
 - **3D Model Preview** - Interactive Three.js viewer for archived prints
 - **3D Model Preview** - Interactive Three.js viewer for archived prints
 - **Real-time Monitoring** - Live printer status via WebSocket with print progress, temperatures, layer count, and more
 - **Real-time Monitoring** - Live printer status via WebSocket with print progress, temperatures, layer count, and more

+ 10 - 0
backend/app/schemas/settings.py

@@ -23,6 +23,12 @@ class AppSettings(BaseModel):
     # Language
     # Language
     notification_language: str = Field(default="en", description="Language for push notifications (en, de)")
     notification_language: str = Field(default="en", description="Language for push notifications (en, de)")
 
 
+    # AMS threshold settings for humidity and temperature coloring
+    ams_humidity_good: int = Field(default=40, description="Humidity threshold for good (green): <= this value")
+    ams_humidity_fair: int = Field(default=60, description="Humidity threshold for fair (orange): <= this value, > is red")
+    ams_temp_good: float = Field(default=28.0, description="Temperature threshold for good (blue): <= this value")
+    ams_temp_fair: float = Field(default=35.0, description="Temperature threshold for fair (orange): <= this value, > is red")
+
 
 
 class AppSettingsUpdate(BaseModel):
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
     """Schema for updating settings (all fields optional)."""
@@ -39,3 +45,7 @@ class AppSettingsUpdate(BaseModel):
     spoolman_sync_mode: str | None = None
     spoolman_sync_mode: str | None = None
     check_updates: bool | None = None
     check_updates: bool | None = None
     notification_language: str | None = None
     notification_language: str | None = None
+    ams_humidity_good: int | None = None
+    ams_humidity_fair: int | None = None
+    ams_temp_good: float | None = None
+    ams_temp_fair: float | None = None

+ 5 - 0
backend/app/services/printer_manager.py

@@ -336,6 +336,9 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
             "tag_uid": vt_tag_uid,
             "tag_uid": vt_tag_uid,
         }
         }
 
 
+    # Get ams_extruder_map from raw_data (populated by MQTT handler from AMS info field)
+    ams_extruder_map = raw_data.get("ams_extruder_map", {})
+
     result = {
     result = {
         "connected": state.connected,
         "connected": state.connected,
         "state": state.state,
         "state": state.state,
@@ -358,6 +361,8 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
         "ams_status_main": state.ams_status_main,
         "ams_status_main": state.ams_status_main,
         "ams_status_sub": state.ams_status_sub,
         "ams_status_sub": state.ams_status_sub,
         "tray_now": state.tray_now,
         "tray_now": state.tray_now,
+        # Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left
+        "ams_extruder_map": ams_extruder_map,
         # WiFi signal strength
         # WiFi signal strength
         "wifi_signal": state.wifi_signal,
         "wifi_signal": state.wifi_signal,
     }
     }

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

@@ -247,6 +247,11 @@ export interface AppSettings {
   energy_tracking_mode: 'print' | 'total';
   energy_tracking_mode: 'print' | 'total';
   check_updates: boolean;
   check_updates: boolean;
   notification_language: string;
   notification_language: string;
+  // AMS threshold settings
+  ams_humidity_good: number;  // <= this is green
+  ams_humidity_fair: number;  // <= this is orange, > is red
+  ams_temp_good: number;      // <= this is green/blue
+  ams_temp_fair: number;      // <= this is orange, > is red
 }
 }
 
 
 export type AppSettingsUpdate = Partial<AppSettings>;
 export type AppSettingsUpdate = Partial<AppSettings>;

+ 130 - 19
frontend/src/pages/PrintersPage.tsx

@@ -83,23 +83,63 @@ function WaterDropFull({ className }: { className?: string }) {
   );
   );
 }
 }
 
 
+// Thermometer SVG - empty outline
+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>
+  );
+}
+
+// Thermometer SVG - half filled (gold - same as humidity fair)
+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>
+  );
+}
+
+// Thermometer SVG - fully filled (red - same as humidity bad)
+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>
+  );
+}
+
 // Humidity indicator with water drop that fills based on level (Bambu Lab style)
 // Humidity indicator with water drop that fills based on level (Bambu Lab style)
 // Reference: https://github.com/theicedmango/bambu-humidity
 // Reference: https://github.com/theicedmango/bambu-humidity
-function HumidityIndicator({ humidity }: { humidity: number | string }) {
+interface HumidityIndicatorProps {
+  humidity: number | string;
+  goodThreshold?: number;  // <= this is green
+  fairThreshold?: number;  // <= this is orange, > is red
+}
+
+function HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60 }: HumidityIndicatorProps) {
   const humidityValue = typeof humidity === 'string' ? parseInt(humidity, 10) : humidity;
   const humidityValue = typeof humidity === 'string' ? parseInt(humidity, 10) : humidity;
+  const good = typeof goodThreshold === 'number' ? goodThreshold : 40;
+  const fair = typeof fairThreshold === 'number' ? fairThreshold : 60;
 
 
-  // Status thresholds from bambu-humidity:
-  // Good: ≤40% (green #22a352), Fair: 41-60% (gold #d4a017), Bad: >60% (red #c62828)
+  // Status thresholds (configurable via settings)
+  // Good: ≤goodThreshold (green #22a352), Fair: ≤fairThreshold (gold #d4a017), Bad: >fairThreshold (red #c62828)
   let textColor: string;
   let textColor: string;
   let statusText: string;
   let statusText: string;
 
 
   if (isNaN(humidityValue)) {
   if (isNaN(humidityValue)) {
     textColor = '#C3C2C1';
     textColor = '#C3C2C1';
     statusText = 'Unknown';
     statusText = 'Unknown';
-  } else if (humidityValue <= 40) {
+  } else if (humidityValue <= good) {
     textColor = '#22a352'; // Green - Good
     textColor = '#22a352'; // Green - Good
     statusText = 'Good';
     statusText = 'Good';
-  } else if (humidityValue <= 60) {
+  } else if (humidityValue <= fair) {
     textColor = '#d4a017'; // Gold - Fair
     textColor = '#d4a017'; // Gold - Fair
     statusText = 'Fair';
     statusText = 'Fair';
   } else {
   } else {
@@ -107,25 +147,60 @@ function HumidityIndicator({ humidity }: { humidity: number | string }) {
     statusText = 'Bad';
     statusText = 'Bad';
   }
   }
 
 
-  // Fill level based on humidity (matching bambu-humidity visual style)
-  // ≤40% (Good): Half fill, 41-60% (Fair): Full fill, >60% (Bad): Full fill
+  // Fill level based on status: Good=Empty (dry), Fair=Half, Bad=Full (wet)
   let DropComponent: React.FC<{ className?: string }>;
   let DropComponent: React.FC<{ className?: string }>;
   if (isNaN(humidityValue)) {
   if (isNaN(humidityValue)) {
     DropComponent = WaterDropEmpty;
     DropComponent = WaterDropEmpty;
-  } else if (humidityValue <= 40) {
-    DropComponent = WaterDropHalf;
+  } else if (humidityValue <= good) {
+    DropComponent = WaterDropEmpty; // Good - empty drop (dry)
+  } else if (humidityValue <= fair) {
+    DropComponent = WaterDropHalf; // Fair - half filled
   } else {
   } else {
-    DropComponent = WaterDropFull;
+    DropComponent = WaterDropFull; // Bad - full (too humid)
   }
   }
 
 
   return (
   return (
-    <div className="flex items-center gap-1" title={`Humidity: ${humidityValue}% - ${statusText}`}>
+    <div className="flex items-center justify-end gap-1" title={`Humidity: ${humidityValue}% - ${statusText}`}>
       <DropComponent className="w-3 h-4" />
       <DropComponent className="w-3 h-4" />
-      <span className="text-xs font-medium" style={{ color: textColor }}>{humidityValue}%</span>
+      <span className="text-xs font-medium tabular-nums w-8 text-right" style={{ color: textColor }}>{humidityValue}%</span>
     </div>
     </div>
   );
   );
 }
 }
 
 
+// Temperature indicator with dynamic icon and coloring
+interface TemperatureIndicatorProps {
+  temp: number;
+  goodThreshold?: number;  // <= this is blue
+  fairThreshold?: number;  // <= this is orange, > is red
+}
+
+function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35 }: TemperatureIndicatorProps) {
+  // Ensure thresholds are numbers
+  const good = typeof goodThreshold === 'number' ? goodThreshold : 28;
+  const fair = typeof fairThreshold === 'number' ? fairThreshold : 35;
+
+  let textColor: string;
+  let ThermoComponent: React.FC<{ className?: string }>;
+
+  if (temp <= good) {
+    textColor = '#22a352'; // Green - good (same as humidity)
+    ThermoComponent = ThermometerEmpty;
+  } else if (temp <= fair) {
+    textColor = '#d4a017'; // Gold - fair (same as humidity)
+    ThermoComponent = ThermometerHalf;
+  } else {
+    textColor = '#c62828'; // Red - bad (same as humidity)
+    ThermoComponent = ThermometerFull;
+  }
+
+  return (
+    <span className="flex items-center gap-1" title="Temperature">
+      <ThermoComponent className="w-3 h-4" />
+      <span className="tabular-nums w-12 text-right" style={{ color: textColor }}>{temp}°C</span>
+    </span>
+  );
+}
+
 // Get AMS label: AMS-A/B/C/D for regular AMS, HT-A/B for AMS-HT (single spool)
 // Get AMS label: AMS-A/B/C/D for regular AMS, HT-A/B for AMS-HT (single spool)
 // Always use tray count as the source of truth (1 tray = AMS-HT, 4 trays = regular AMS)
 // Always use tray count as the source of truth (1 tray = AMS-HT, 4 trays = regular AMS)
 // AMS-HT uses IDs 128+ while regular AMS uses 0-3
 // AMS-HT uses IDs 128+ while regular AMS uses 0-3
@@ -259,10 +334,14 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
     let printing = 0;
     let printing = 0;
     let idle = 0;
     let idle = 0;
     let offline = 0;
     let offline = 0;
+    let loading = 0;
 
 
     printers?.forEach((printer) => {
     printers?.forEach((printer) => {
       const status = queryClient.getQueryData<{ connected: boolean; state: string | null }>(['printerStatus', printer.id]);
       const status = queryClient.getQueryData<{ connected: boolean; state: string | null }>(['printerStatus', printer.id]);
-      if (!status?.connected) {
+      if (status === undefined) {
+        // Status not yet loaded - don't count as offline yet
+        loading++;
+      } else if (!status.connected) {
         offline++;
         offline++;
       } else if (status.state === 'RUNNING') {
       } else if (status.state === 'RUNNING') {
         printing++;
         printing++;
@@ -271,7 +350,7 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
       }
       }
     });
     });
 
 
-    return { printing, idle, offline, total: (printers?.length || 0) };
+    return { printing, idle, offline, loading, total: (printers?.length || 0) };
   }, [printers, queryClient]);
   }, [printers, queryClient]);
 
 
   // Subscribe to query cache changes to re-render when status updates
   // Subscribe to query cache changes to re-render when status updates
@@ -323,11 +402,18 @@ function PrinterCard({
   hideIfDisconnected,
   hideIfDisconnected,
   maintenanceInfo,
   maintenanceInfo,
   viewMode = 'expanded',
   viewMode = 'expanded',
+  amsThresholds,
 }: {
 }: {
   printer: Printer;
   printer: Printer;
   hideIfDisconnected?: boolean;
   hideIfDisconnected?: boolean;
   maintenanceInfo?: PrinterMaintenanceInfo;
   maintenanceInfo?: PrinterMaintenanceInfo;
   viewMode?: ViewMode;
   viewMode?: ViewMode;
+  amsThresholds?: {
+    humidityGood: number;
+    humidityFair: number;
+    tempGood: number;
+    tempFair: number;
+  };
 }) {
 }) {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const navigate = useNavigate();
   const navigate = useNavigate();
@@ -851,13 +937,20 @@ function PrinterCard({
                         {(ams.humidity != null || ams.temp != null) && (
                         {(ams.humidity != null || ams.temp != null) && (
                           <div className="flex items-center gap-3 text-xs">
                           <div className="flex items-center gap-3 text-xs">
                             {ams.humidity != null && (
                             {ams.humidity != null && (
-                              <HumidityIndicator humidity={ams.humidity} />
+                              <div className="w-14 text-right">
+                                <HumidityIndicator
+                                  humidity={ams.humidity}
+                                  goodThreshold={amsThresholds?.humidityGood}
+                                  fairThreshold={amsThresholds?.humidityFair}
+                                />
+                              </div>
                             )}
                             )}
                             {ams.temp != null && (
                             {ams.temp != null && (
-                              <span className="flex items-center gap-1 text-orange-400" title="Temperature">
-                                <Thermometer className="w-3 h-3" />
-                                {ams.temp}°C
-                              </span>
+                              <TemperatureIndicator
+                                temp={ams.temp}
+                                goodThreshold={amsThresholds?.tempGood}
+                                fairThreshold={amsThresholds?.tempFair}
+                              />
                             )}
                             )}
                           </div>
                           </div>
                         )}
                         )}
@@ -1480,6 +1573,12 @@ export function PrintersPage() {
     queryFn: api.getPrinters,
     queryFn: api.getPrinters,
   });
   });
 
 
+  // Fetch app settings for AMS thresholds
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
   // Fetch all smart plugs to know which printers have them
   // Fetch all smart plugs to know which printers have them
   const { data: smartPlugs } = useQuery({
   const { data: smartPlugs } = useQuery({
     queryKey: ['smart-plugs'],
     queryKey: ['smart-plugs'],
@@ -1761,6 +1860,12 @@ export function PrintersPage() {
                     hideIfDisconnected={hideDisconnected}
                     hideIfDisconnected={hideDisconnected}
                     maintenanceInfo={maintenanceByPrinter[printer.id]}
                     maintenanceInfo={maintenanceByPrinter[printer.id]}
                     viewMode={viewMode}
                     viewMode={viewMode}
+                    amsThresholds={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}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>
@@ -1781,6 +1886,12 @@ export function PrintersPage() {
               hideIfDisconnected={hideDisconnected}
               hideIfDisconnected={hideDisconnected}
               maintenanceInfo={maintenanceByPrinter[printer.id]}
               maintenanceInfo={maintenanceByPrinter[printer.id]}
               viewMode={viewMode}
               viewMode={viewMode}
+              amsThresholds={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}
             />
             />
           ))}
           ))}
         </div>
         </div>

+ 109 - 2
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Save, Loader2, Check, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe } from 'lucide-react';
+import { Save, Loader2, Check, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { AppSettings, SmartPlug, NotificationProvider, UpdateStatus } from '../api/client';
 import type { AppSettings, SmartPlug, NotificationProvider, UpdateStatus } from '../api/client';
@@ -106,7 +106,11 @@ export function SettingsPage() {
         settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
         settings.energy_cost_per_kwh !== localSettings.energy_cost_per_kwh ||
         settings.energy_tracking_mode !== localSettings.energy_tracking_mode ||
         settings.energy_tracking_mode !== localSettings.energy_tracking_mode ||
         settings.check_updates !== localSettings.check_updates ||
         settings.check_updates !== localSettings.check_updates ||
-        settings.notification_language !== localSettings.notification_language;
+        settings.notification_language !== localSettings.notification_language ||
+        settings.ams_humidity_good !== localSettings.ams_humidity_good ||
+        settings.ams_humidity_fair !== localSettings.ams_humidity_fair ||
+        settings.ams_temp_good !== localSettings.ams_temp_good ||
+        settings.ams_temp_fair !== localSettings.ams_temp_fair;
       setHasChanges(changed);
       setHasChanges(changed);
     }
     }
   }, [settings, localSettings]);
   }, [settings, localSettings]);
@@ -382,6 +386,109 @@ export function SettingsPage() {
               </div>
               </div>
             </CardContent>
             </CardContent>
           </Card>
           </Card>
+
+          <Card>
+            <CardHeader>
+              <h2 className="text-lg font-semibold text-white">AMS Display Thresholds</h2>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <p className="text-sm text-bambu-gray">
+                Configure color thresholds for AMS humidity and temperature indicators.
+              </p>
+
+              {/* Humidity Thresholds */}
+              <div className="space-y-3">
+                <div className="flex items-center gap-2 text-white">
+                  <Droplets className="w-4 h-4 text-blue-400" />
+                  <span className="font-medium">Humidity</span>
+                </div>
+                <div className="grid grid-cols-2 gap-3">
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      Good (green) ≤
+                    </label>
+                    <div className="flex items-center gap-2">
+                      <input
+                        type="number"
+                        min="0"
+                        max="100"
+                        value={localSettings.ams_humidity_good ?? 40}
+                        onChange={(e) => updateSetting('ams_humidity_good', parseInt(e.target.value) || 40)}
+                        className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      />
+                      <span className="text-bambu-gray">%</span>
+                    </div>
+                  </div>
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      Fair (orange) ≤
+                    </label>
+                    <div className="flex items-center gap-2">
+                      <input
+                        type="number"
+                        min="0"
+                        max="100"
+                        value={localSettings.ams_humidity_fair ?? 60}
+                        onChange={(e) => updateSetting('ams_humidity_fair', parseInt(e.target.value) || 60)}
+                        className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      />
+                      <span className="text-bambu-gray">%</span>
+                    </div>
+                  </div>
+                </div>
+                <p className="text-xs text-bambu-gray">
+                  Above fair threshold shows as red (bad)
+                </p>
+              </div>
+
+              {/* Temperature Thresholds */}
+              <div className="space-y-3 pt-2 border-t border-bambu-dark-tertiary">
+                <div className="flex items-center gap-2 text-white">
+                  <Thermometer className="w-4 h-4 text-orange-400" />
+                  <span className="font-medium">Temperature</span>
+                </div>
+                <div className="grid grid-cols-2 gap-3">
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      Good (blue) ≤
+                    </label>
+                    <div className="flex items-center gap-2">
+                      <input
+                        type="number"
+                        step="0.5"
+                        min="0"
+                        max="60"
+                        value={localSettings.ams_temp_good ?? 28}
+                        onChange={(e) => updateSetting('ams_temp_good', parseFloat(e.target.value) || 28)}
+                        className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      />
+                      <span className="text-bambu-gray">°C</span>
+                    </div>
+                  </div>
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      Fair (orange) ≤
+                    </label>
+                    <div className="flex items-center gap-2">
+                      <input
+                        type="number"
+                        step="0.5"
+                        min="0"
+                        max="60"
+                        value={localSettings.ams_temp_fair ?? 35}
+                        onChange={(e) => updateSetting('ams_temp_fair', parseFloat(e.target.value) || 35)}
+                        className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      />
+                      <span className="text-bambu-gray">°C</span>
+                    </div>
+                  </div>
+                </div>
+                <p className="text-xs text-bambu-gray">
+                  Above fair threshold shows as red (hot)
+                </p>
+              </div>
+            </CardContent>
+          </Card>
         </div>
         </div>
 
 
         {/* Second Column - Spoolman & Updates */}
         {/* Second Column - Spoolman & Updates */}

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


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


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-DzQp7Kty.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-SGO6jLr2.css">
+    <script type="module" crossorigin src="/assets/index-CUSzovgk.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DL3S7zom.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

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