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)
This commit is contained in:
maziggy
2025-12-07 14:56:52 +00:00
parent e688c35719
commit 71e15b0ed7
9 changed files with 679 additions and 435 deletions
+6
View File
@@ -22,6 +22,12 @@ v∆v
## Features
- **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
- **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
+10
View File
@@ -23,6 +23,12 @@ class AppSettings(BaseModel):
# Language
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):
"""Schema for updating settings (all fields optional)."""
@@ -39,3 +45,7 @@ class AppSettingsUpdate(BaseModel):
spoolman_sync_mode: str | None = None
check_updates: bool | 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
View File
@@ -336,6 +336,9 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
"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 = {
"connected": state.connected,
"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_sub": state.ams_status_sub,
"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": state.wifi_signal,
}
+5
View File
@@ -247,6 +247,11 @@ export interface AppSettings {
energy_tracking_mode: 'print' | 'total';
check_updates: boolean;
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>;
+131 -20
View File
@@ -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)
// Reference: https://github.com/theicedmango/bambu-humidity
function HumidityIndicator({ humidity }: { humidity: number | string }) {
const humidityValue = typeof humidity === 'string' ? parseInt(humidity, 10) : humidity;
interface HumidityIndicatorProps {
humidity: number | string;
goodThreshold?: number; // <= this is green
fairThreshold?: number; // <= this is orange, > is red
}
// Status thresholds from bambu-humidity:
// Good: ≤40% (green #22a352), Fair: 41-60% (gold #d4a017), Bad: >60% (red #c62828)
function HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60 }: HumidityIndicatorProps) {
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 (configurable via settings)
// Good: ≤goodThreshold (green #22a352), Fair: ≤fairThreshold (gold #d4a017), Bad: >fairThreshold (red #c62828)
let textColor: string;
let statusText: string;
if (isNaN(humidityValue)) {
textColor = '#C3C2C1';
statusText = 'Unknown';
} else if (humidityValue <= 40) {
} else if (humidityValue <= good) {
textColor = '#22a352'; // Green - Good
statusText = 'Good';
} else if (humidityValue <= 60) {
} else if (humidityValue <= fair) {
textColor = '#d4a017'; // Gold - Fair
statusText = 'Fair';
} else {
@@ -107,25 +147,60 @@ function HumidityIndicator({ humidity }: { humidity: number | string }) {
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 }>;
if (isNaN(humidityValue)) {
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 {
DropComponent = WaterDropFull;
DropComponent = WaterDropFull; // Bad - full (too humid)
}
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" />
<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>
);
}
// 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)
// 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
@@ -259,10 +334,14 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
let printing = 0;
let idle = 0;
let offline = 0;
let loading = 0;
printers?.forEach((printer) => {
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++;
} else if (status.state === 'RUNNING') {
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]);
// Subscribe to query cache changes to re-render when status updates
@@ -323,11 +402,18 @@ function PrinterCard({
hideIfDisconnected,
maintenanceInfo,
viewMode = 'expanded',
amsThresholds,
}: {
printer: Printer;
hideIfDisconnected?: boolean;
maintenanceInfo?: PrinterMaintenanceInfo;
viewMode?: ViewMode;
amsThresholds?: {
humidityGood: number;
humidityFair: number;
tempGood: number;
tempFair: number;
};
}) {
const queryClient = useQueryClient();
const navigate = useNavigate();
@@ -851,13 +937,20 @@ function PrinterCard({
{(ams.humidity != null || ams.temp != null) && (
<div className="flex items-center gap-3 text-xs">
{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 && (
<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>
)}
@@ -1480,6 +1573,12 @@ export function PrintersPage() {
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
const { data: smartPlugs } = useQuery({
queryKey: ['smart-plugs'],
@@ -1761,6 +1860,12 @@ export function PrintersPage() {
hideIfDisconnected={hideDisconnected}
maintenanceInfo={maintenanceByPrinter[printer.id]}
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>
@@ -1781,6 +1886,12 @@ export function PrintersPage() {
hideIfDisconnected={hideDisconnected}
maintenanceInfo={maintenanceByPrinter[printer.id]}
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>
+109 -2
View File
@@ -1,5 +1,5 @@
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 { api } 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_tracking_mode !== localSettings.energy_tracking_mode ||
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);
}
}, [settings, localSettings]);
@@ -382,6 +386,109 @@ export function SettingsPage() {
</div>
</CardContent>
</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>
{/* Second Column - Spoolman & Updates */}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<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="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>
<body>
<div id="root"></div>