Add H2C nozzle rack support — store and display 6-position tool-changer dock

The H2C printer has a tool-changer with a 6-nozzle rack, but
device.nozzle.info entries beyond index 1 were being dropped due to a
hardcoded 2-nozzle limit. This adds full nozzle rack storage, API
exposure, and a frontend card showing all dock positions.
This commit is contained in:
maziggy
2026-02-09 09:21:50 +01:00
parent 30fba33dcf
commit 48174d3a08
13 changed files with 155 additions and 49 deletions
+14
View File
@@ -19,6 +19,7 @@ from backend.app.schemas.printer import (
AMSUnit,
HMSErrorResponse,
NozzleInfoResponse,
NozzleRackSlot,
PrinterCreate,
PrinterResponse,
PrinterStatus,
@@ -360,6 +361,18 @@ async def get_printer_status(
for n in (state.nozzles or [])
]
# H2C nozzle rack (tool-changer dock positions)
nozzle_rack = [
NozzleRackSlot(
id=n.get("id", 0),
nozzle_type=n.get("type", ""),
nozzle_diameter=n.get("diameter", ""),
wear=n.get("wear"),
stat=n.get("stat"),
)
for n in (state.nozzle_rack or [])
]
# Convert print options to response format
print_options = PrintOptionsResponse(
spaghetti_detector=state.print_options.spaghetti_detector,
@@ -423,6 +436,7 @@ async def get_printer_status(
ipcam=state.ipcam,
wifi_signal=state.wifi_signal,
nozzles=nozzles,
nozzle_rack=nozzle_rack,
print_options=print_options,
stg_cur=state.stg_cur,
stg_cur_name=get_derived_status_name(state, printer.model),
+11
View File
@@ -145,6 +145,16 @@ class NozzleInfoResponse(BaseModel):
nozzle_diameter: str = "" # e.g., "0.4"
class NozzleRackSlot(BaseModel):
"""H2C nozzle rack slot (6-position tool-changer dock)."""
id: int = 0
nozzle_type: str = ""
nozzle_diameter: str = ""
wear: int | None = None
stat: int | None = None # Nozzle status (e.g. mounted/docked)
class PrintOptionsResponse(BaseModel):
"""AI detection and print options from xcam data."""
@@ -191,6 +201,7 @@ class PrinterStatus(BaseModel):
ipcam: bool = False # Live view enabled
wifi_signal: int | None = None # WiFi signal strength in dBm
nozzles: list[NozzleInfoResponse] = [] # Nozzle hardware info (index 0=left/primary, 1=right)
nozzle_rack: list[NozzleRackSlot] = [] # H2C 6-nozzle tool-changer rack
print_options: PrintOptionsResponse | None = None # AI detection and print options
# Calibration stage tracking
stg_cur: int = -1 # Current stage number (-1 = not calibrating)
+15 -1
View File
@@ -147,6 +147,8 @@ class PrinterState:
# H2D per-extruder tray_now from snow field: {extruder_id: normalized_global_tray_id}
# snow encodes AMS ID in high byte: ams_id = snow >> 8, slot = snow & 0xFF
h2d_extruder_snow: dict = field(default_factory=dict)
# H2C nozzle rack: full device.nozzle.info array for tool-changer printers (>2 nozzles)
nozzle_rack: list = field(default_factory=list)
# Timestamp of last AMS data update (for RFID refresh detection)
last_ams_update: float = 0.0
# Printable objects for skip object functionality: {identify_id: object_name}
@@ -1740,12 +1742,24 @@ class BambuMQTTClient:
if "nozzle_diameter_2" in data:
self.state.nozzles[1].nozzle_diameter = str(data["nozzle_diameter_2"])
# H2D series: Nozzle hardware info is in device.nozzle.info array
# H2D/H2C series: Nozzle hardware info is in device.nozzle.info array
if "device" in data and isinstance(data["device"], dict):
device = data["device"]
nozzle_data = device.get("nozzle", {})
nozzle_info = nozzle_data.get("info", [])
if isinstance(nozzle_info, list):
# H2C tool-changer: >2 entries means nozzle rack (6 dock + 1 mounted = 7)
if len(nozzle_info) > 2:
self.state.nozzle_rack = [
{
"id": n.get("id", i),
"type": str(n.get("type", "")),
"diameter": str(n.get("diameter", "")),
"wear": n.get("wear"),
"stat": n.get("stat"),
}
for i, n in enumerate(nozzle_info)
]
for nozzle in nozzle_info:
idx = nozzle.get("id", 0)
if idx < len(self.state.nozzles):
+2
View File
@@ -647,6 +647,8 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
"chamber_light": state.chamber_light,
# Active extruder for dual-nozzle printers (0=right, 1=left)
"active_extruder": state.active_extruder,
# H2C nozzle rack (tool-changer dock positions)
"nozzle_rack": state.nozzle_rack or [],
}
# Add cover URL if there's an active print and printer_id is provided
# Include PAUSE/PAUSED states so skip objects modal can show cover
+9
View File
@@ -129,6 +129,14 @@ export interface NozzleInfo {
nozzle_diameter: string; // e.g., "0.4"
}
export interface NozzleRackSlot {
id: number;
nozzle_type: string;
nozzle_diameter: string;
wear: number | null;
stat: number | null; // Nozzle status (e.g. mounted/docked)
}
export interface PrintOptions {
// Core AI detectors
spaghetti_detector: boolean;
@@ -186,6 +194,7 @@ export interface PrinterStatus {
ipcam: boolean; // Live view enabled
wifi_signal: number | null; // WiFi signal strength in dBm
nozzles: NozzleInfo[]; // Nozzle hardware info (index 0=left/primary, 1=right)
nozzle_rack: NozzleRackSlot[]; // H2C 6-nozzle tool-changer rack
print_options: PrintOptions | null; // AI detection and print options
// Calibration stage tracking
stg_cur: number; // Current stage number (-1 = not calibrating)
+1
View File
@@ -207,6 +207,7 @@ export default {
reconnect: 'Neu verbinden',
mqttDebug: 'MQTT-Debug',
activeNozzle: 'Aktiv: {{nozzle}} Düse',
nozzleRack: 'Düsenhalter',
// Firmware
firmwareUpdate: 'Firmware-Update',
firmwareInstructions: 'Gehen Sie auf dem Touchscreen des Druckers zu',
+1
View File
@@ -207,6 +207,7 @@ export default {
reconnect: 'Reconnect',
mqttDebug: 'MQTT Debug',
activeNozzle: 'Active: {{nozzle}} nozzle',
nozzleRack: 'Nozzle Rack',
// Firmware
firmwareUpdate: 'Firmware Update',
firmwareInstructions: 'On the printer\'s touchscreen, go to',
+1
View File
@@ -191,6 +191,7 @@ export default {
reconnect: '再接続',
mqttDebug: 'MQTTデバッグ',
activeNozzle: 'アクティブ: {{side}}ノズル',
nozzleRack: 'ノズルラック',
toast: {
printStopped: '印刷を停止しました',
printPaused: '印刷を一時停止しました',
+53
View File
@@ -410,6 +410,55 @@ function NozzleBadge({ side }: { side: 'L' | 'R' }) {
);
}
// H2C Nozzle Rack Card — 2×3 grid showing 6-position tool-changer dock
function NozzleRackCard({ slots }: { slots: import('../api/client').NozzleRackSlot[] }) {
const { t } = useTranslation();
// Filter to dock slots only (exclude the mounted/active entry which is typically id=0 or stat indicates mounted)
// Show up to 6 dock positions
const dockSlots = slots.slice(0, 6);
return (
<div className="text-center px-2 py-1.5 bg-bambu-dark rounded-lg" style={{ minWidth: '80px' }}>
<p className="text-[9px] text-bambu-gray mb-0.5">{t('printers.nozzleRack')}</p>
<div className="grid grid-cols-3 gap-0.5">
{dockSlots.map((slot, i) => {
const isEmpty = !slot.nozzle_diameter && !slot.nozzle_type;
const isMounted = slot.stat === 1;
// Type abbreviation: S=stainless, H=hardened
const typeAbbr = slot.nozzle_type?.includes('hardened') ? 'H' : slot.nozzle_type?.includes('stainless') ? 'S' : '';
return (
<div
key={slot.id ?? i}
className={`rounded px-0.5 py-0.5 text-center ${
isEmpty
? 'bg-bambu-dark-tertiary/30 opacity-40'
: isMounted
? 'bg-green-900/40 ring-1 ring-green-500/60'
: 'bg-bambu-dark-tertiary/50'
}`}
title={isEmpty ? `Slot ${i + 1}: empty` : `Slot ${i + 1}: ${slot.nozzle_diameter}mm ${slot.nozzle_type || ''} ${isMounted ? '(mounted)' : ''}`}
>
{isEmpty ? (
<p className="text-[9px] text-bambu-gray">—</p>
) : (
<>
<p className={`text-[10px] font-medium ${isMounted ? 'text-green-400' : 'text-white'}`}>
{slot.nozzle_diameter || '?'}
</p>
{typeAbbr && (
<p className="text-[8px] text-bambu-gray leading-none">{typeAbbr}</p>
)}
</>
)}
</div>
);
})}
</div>
</div>
);
}
// Water drop SVG - empty outline (Bambu Lab style from bambu-humidity)
function WaterDropEmpty({ className }: { className?: string }) {
return (
@@ -2009,6 +2058,10 @@ function PrinterCard({
<p className={`text-[11px] font-bold ${activeNozzle === 'R' ? 'text-amber-400' : 'text-gray-500'}`}>R</p>
</div>
)}
{/* H2C nozzle rack (tool-changer dock) */}
{status.nozzle_rack && status.nozzle_rack.length > 0 && (
<NozzleRackCard slots={status.nozzle_rack} />
)}
</div>
);
})()}
File diff suppressed because one or more lines are too long
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
@@ -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-C1lCCYZa.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BAHqB1ol.css">
<script type="module" crossorigin src="/assets/index-Brr6L6Pf.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BTJM8cN7.css">
</head>
<body>
<div id="root"></div>