mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
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:
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -191,6 +191,7 @@ export default {
|
||||
reconnect: '再接続',
|
||||
mqttDebug: 'MQTTデバッグ',
|
||||
activeNozzle: 'アクティブ: {{side}}ノズル',
|
||||
nozzleRack: 'ノズルラック',
|
||||
toast: {
|
||||
printStopped: '印刷を停止しました',
|
||||
printPaused: '印刷を一時停止しました',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user