Martin Ziegler 5 месяцев назад
Родитель
Сommit
0ab0707a87

+ 138 - 50
backend/app/services/bambu_mqtt.py

@@ -645,27 +645,64 @@ class BambuMQTTClient:
             temps["bed"] = float(data["bed_temper"])
             temps["bed"] = float(data["bed_temper"])
         if "bed_target_temper" in data:
         if "bed_target_temper" in data:
             temps["bed_target"] = float(data["bed_target_temper"])
             temps["bed_target"] = float(data["bed_target_temper"])
+        # Check if this is H2D (has device.extruder.info with 2 extruders)
+        has_h2d_extruder_info = (
+            "device" in data and
+            isinstance(data.get("device"), dict) and
+            "extruder" in data["device"] and
+            isinstance(data["device"]["extruder"].get("info"), list) and
+            len(data["device"]["extruder"]["info"]) >= 2
+        )
+
+        # Standard nozzle fields: these are for the RIGHT/default nozzle on H2D
+        # For H2D, we use these for nozzle_2 (RIGHT), for others use as nozzle (primary)
         if "nozzle_temper" in data:
         if "nozzle_temper" in data:
-            temps["nozzle"] = float(data["nozzle_temper"])
+            if has_h2d_extruder_info:
+                temps["nozzle_2"] = float(data["nozzle_temper"])  # RIGHT nozzle on H2D
+            else:
+                temps["nozzle"] = float(data["nozzle_temper"])
         if "nozzle_target_temper" in data:
         if "nozzle_target_temper" in data:
-            temps["nozzle_target"] = float(data["nozzle_target_temper"])
-        # Second nozzle for dual-extruder printers (H2 series)
-        # Try multiple possible field names used by different firmware versions
-        if "nozzle_temper_2" in data:
-            temps["nozzle_2"] = float(data["nozzle_temper_2"])
-        elif "right_nozzle_temper" in data:
-            temps["nozzle_2"] = float(data["right_nozzle_temper"])
-        if "nozzle_target_temper_2" in data:
-            temps["nozzle_2_target"] = float(data["nozzle_target_temper_2"])
-        elif "right_nozzle_target_temper" in data:
-            temps["nozzle_2_target"] = float(data["right_nozzle_target_temper"])
-        # Also check for left nozzle as primary (some H2 models)
-        if "left_nozzle_temper" in data and "nozzle" not in temps:
-            temps["nozzle"] = float(data["left_nozzle_temper"])
-        if "left_nozzle_target_temper" in data and "nozzle_target" not in temps:
-            temps["nozzle_target"] = float(data["left_nozzle_target_temper"])
+            if has_h2d_extruder_info:
+                temps["nozzle_2_target"] = float(data["nozzle_target_temper"])  # RIGHT target on H2D
+            else:
+                temps["nozzle_target"] = float(data["nozzle_target_temper"])
+        # Second nozzle for dual-extruder printers - skip for H2D (uses device.extruder.info instead)
+        if not has_h2d_extruder_info:
+            # Try multiple possible field names used by different firmware versions
+            if "nozzle_temper_2" in data:
+                val = float(data["nozzle_temper_2"])
+                if -50 < val < 500:  # Valid temp range
+                    temps["nozzle_2"] = val
+                else:
+                    logger.debug(f"[{self.serial_number}] nozzle_temper_2={val} out of range")
+            elif "right_nozzle_temper" in data:
+                val = float(data["right_nozzle_temper"])
+                if -50 < val < 500:  # Valid temp range
+                    temps["nozzle_2"] = val
+                else:
+                    logger.debug(f"[{self.serial_number}] right_nozzle_temper={val} out of range")
+            if "nozzle_target_temper_2" in data:
+                val = float(data["nozzle_target_temper_2"])
+                if 0 <= val < 500:  # Valid temp range
+                    temps["nozzle_2_target"] = val
+                else:
+                    logger.debug(f"[{self.serial_number}] nozzle_target_temper_2={val} out of range")
+            elif "right_nozzle_target_temper" in data:
+                val = float(data["right_nozzle_target_temper"])
+                if 0 <= val < 500:  # Valid temp range
+                    temps["nozzle_2_target"] = val
+                else:
+                    logger.debug(f"[{self.serial_number}] right_nozzle_target_temper={val} out of range")
+            # Also check for left nozzle as primary (some H2 models)
+            if "left_nozzle_temper" in data and "nozzle" not in temps:
+                temps["nozzle"] = float(data["left_nozzle_temper"])
+            if "left_nozzle_target_temper" in data and "nozzle_target" not in temps:
+                temps["nozzle_target"] = float(data["left_nozzle_target_temper"])
         if "chamber_temper" in data:
         if "chamber_temper" in data:
             temps["chamber"] = float(data["chamber_temper"])
             temps["chamber"] = float(data["chamber_temper"])
+        # Chamber target temperature (set by print file or display)
+        if "mc_target_cham" in data:
+            temps["chamber_target"] = float(data["mc_target_cham"])
         # H2D series: Chamber temp is in info.temp (directly in °C)
         # H2D series: Chamber temp is in info.temp (directly in °C)
         try:
         try:
             if "info" in data and isinstance(data["info"], dict):
             if "info" in data and isinstance(data["info"], dict):
@@ -680,37 +717,85 @@ class BambuMQTTClient:
                 extruder_data = device.get("extruder", {})
                 extruder_data = device.get("extruder", {})
                 extruder_info = extruder_data.get("info", [])
                 extruder_info = extruder_data.get("info", [])
                 if isinstance(extruder_info, list) and len(extruder_info) >= 1:
                 if isinstance(extruder_info, list) and len(extruder_info) >= 1:
-                    # Log extruder info structure for debugging (once)
-                    if not getattr(self, '_extruder_info_logged', False):
-                        logger.debug(f"[{self.serial_number}] H2D extruder info[0]: {extruder_info[0]}")
-                        if len(extruder_info) >= 2:
-                            logger.debug(f"[{self.serial_number}] H2D extruder info[1]: {extruder_info[1]}")
-                        self._extruder_info_logged = True
-                    # Left nozzle (extruder 0) - temp is already in Celsius
-                    if "nozzle" not in temps and "temp" in extruder_info[0]:
+                    # H2D nozzle mapping: id=0 is RIGHT nozzle (default), id=1 is LEFT nozzle
+                    # RIGHT nozzle uses standard nozzle_temper/nozzle_target_temper fields (already parsed above)
+                    # LEFT nozzle uses extruder_info[1] - no standard fields available
+                    # Note: hnow/htar flags are unreliable (static values, not actual heating state)
+                    # Real heating indicator: temp > 500 means encoded (target*65536+current)
+                    # Heating = target > 0 AND current < target
+                    # Right nozzle (extruder 0)
+                    if len(extruder_info) >= 1 and "temp" in extruder_info[0]:
                         temp_val = extruder_info[0]["temp"]
                         temp_val = extruder_info[0]["temp"]
-                        if -50 < temp_val < 500:  # Valid temp range
-                            temps["nozzle"] = float(temp_val)
-                    # Left nozzle target temp - star field, but 65535/65279 means "not set"
-                    if "nozzle_target" not in temps:
-                        star = extruder_info[0].get("star")
-                        if star is not None and 0 <= star < 500:  # Valid temp range
-                            temps["nozzle_target"] = float(star)
-                    # Right nozzle (extruder 1) - only for dual nozzle printers
+                        if temp_val > 500:
+                            target = temp_val // 65536
+                            current = temp_val % 65536
+                            temps["nozzle_2_heating"] = target > 0 and current < target
+                        else:
+                            temps["nozzle_2_heating"] = False
+                    # Left nozzle (extruder 1)
+                    # H2D protocol: temp field encoding depends on value
+                    # - When > 500: encoded as (target * 65536 + current) - heater is ON
+                    # - When < 500: direct Celsius current temp only - heater is OFF
                     if len(extruder_info) >= 2 and "temp" in extruder_info[1]:
                     if len(extruder_info) >= 2 and "temp" in extruder_info[1]:
-                        temp_val = extruder_info[1]["temp"]
-                        if -50 < temp_val < 500:  # Valid temp range
-                            temps["nozzle_2"] = float(temp_val)
-                    # Right nozzle target temp - star field, but 65535/65279 means "not set"
-                    if len(extruder_info) >= 2:
-                        star = extruder_info[1].get("star")
-                        if star is not None and 0 <= star < 500:  # Valid temp range
-                            temps["nozzle_2_target"] = float(star)
+                        ext1 = extruder_info[1]
+                        temp_val = ext1["temp"]
+
+                        # Check if we recently set the target locally (within 5 seconds)
+                        # If so, don't let MQTT data overwrite it
+                        local_set_time = self.state.temperatures.get("_nozzle_target_set_time", 0)
+                        respect_local_target = (time.time() - local_set_time) < 5.0
+
+                        if temp_val > 500:
+                            # Encoded format: temp = target * 65536 + current
+                            target = temp_val // 65536
+                            current = temp_val % 65536
+                            if 0 < target < 500 and not respect_local_target:
+                                temps["nozzle_target"] = float(target)
+                            if -50 < current < 500:
+                                temps["nozzle"] = float(current)
+                            # Heating = encoded AND we're using the MQTT target (not local override)
+                            # If local target is being respected, use local target to determine heating
+                            if respect_local_target:
+                                local_target = self.state.temperatures.get("nozzle_target", 0)
+                                temps["nozzle_heating"] = local_target > 0 and current < local_target
+                            else:
+                                temps["nozzle_heating"] = target > 0 and current < target
+                        elif -50 < temp_val < 500:
+                            # Direct Celsius = heater is OFF (or at target with heater off)
+                            temps["nozzle"] = float(temp_val)
+                            if not respect_local_target:
+                                temps["nozzle_target"] = 0.0
+                            temps["nozzle_heating"] = False  # Direct = not heating
+                # Parse bed heating state from device.bed.info.temp encoding
+                # temp > 500 means encoded (target*65536+current), heating = target > 0 AND current < target
+                bed_data = device.get("bed", {})
+                bed_info = bed_data.get("info", {})
+                if "temp" in bed_info:
+                    temp_val = bed_info["temp"]
+                    if temp_val > 500:
+                        target = temp_val // 65536
+                        current = temp_val % 65536
+                        temps["bed_heating"] = target > 0 and current < target
+                    else:
+                        temps["bed_heating"] = False
                 # Parse chamber temp from device.ctc.info.temp if not already set
                 # Parse chamber temp from device.ctc.info.temp if not already set
                 ctc_data = device.get("ctc", {})
                 ctc_data = device.get("ctc", {})
                 ctc_info = ctc_data.get("info", {})
                 ctc_info = ctc_data.get("info", {})
                 if "temp" in ctc_info and "chamber" not in temps:
                 if "temp" in ctc_info and "chamber" not in temps:
                     temps["chamber"] = float(ctc_info["temp"])
                     temps["chamber"] = float(ctc_info["temp"])
+                # Parse chamber target from ctc.info.target if available
+                if "target" in ctc_info and "chamber_target" not in temps:
+                    temps["chamber_target"] = float(ctc_info["target"])
+                # Parse chamber heating state from temp encoding
+                # temp > 500 means encoded (target*65536+current), heating = target > 0 AND current < target
+                if "temp" in ctc_info:
+                    temp_val = ctc_info["temp"]
+                    if temp_val > 500:
+                        target = temp_val // 65536
+                        current = temp_val % 65536
+                        temps["chamber_heating"] = target > 0 and current < target
+                    else:
+                        temps["chamber_heating"] = False
         except Exception as e:
         except Exception as e:
             logger.warning(f"[{self.serial_number}] Error parsing H2D temperatures: {e}")
             logger.warning(f"[{self.serial_number}] Error parsing H2D temperatures: {e}")
         if temps:
         if temps:
@@ -1650,7 +1735,8 @@ class BambuMQTTClient:
                 "sequence_id": str(self._sequence_id)
                 "sequence_id": str(self._sequence_id)
             }
             }
         }
         }
-        self._client.publish(self.topic_publish, json.dumps(command))
+        # Use QoS 1 for reliable delivery (at least once)
+        self._client.publish(self.topic_publish, json.dumps(command), qos=1)
         logger.debug(f"[{self.serial_number}] Sent G-code: {gcode[:50]}...")
         logger.debug(f"[{self.serial_number}] Sent G-code: {gcode[:50]}...")
         return True
         return True
 
 
@@ -1663,8 +1749,6 @@ class BambuMQTTClient:
         Returns:
         Returns:
             True if command was sent, False otherwise
             True if command was sent, False otherwise
         """
         """
-        # Use M140 for non-blocking (preferred when not waiting)
-        # Note: P1/A1 series with newer firmware may need M190 (blocking)
         return self.send_gcode(f"M140 S{target}")
         return self.send_gcode(f"M140 S{target}")
 
 
     def set_nozzle_temperature(self, target: int, nozzle: int = 0) -> bool:
     def set_nozzle_temperature(self, target: int, nozzle: int = 0) -> bool:
@@ -1672,17 +1756,21 @@ class BambuMQTTClient:
 
 
         Args:
         Args:
             target: Target temperature in Celsius (0 to turn off)
             target: Target temperature in Celsius (0 to turn off)
-            nozzle: Nozzle index (0 for primary, 1 for secondary on H2D)
+            nozzle: Nozzle index (0 for right/default, 1 for left on H2D)
 
 
         Returns:
         Returns:
             True if command was sent, False otherwise
             True if command was sent, False otherwise
         """
         """
         # Use M104 for non-blocking
         # Use M104 for non-blocking
-        # For dual nozzle (H2D), T parameter selects the tool
-        if nozzle == 0:
-            return self.send_gcode(f"M104 S{target}")
-        else:
-            return self.send_gcode(f"M104 T{nozzle} S{target}")
+        # Always use T parameter for H2D compatibility
+        result = self.send_gcode(f"M104 T{nozzle} S{target}")
+        # H2D quirk: left nozzle (nozzle=1) target isn't reported in MQTT
+        # Track it locally so we can display it correctly
+        if result and nozzle == 1:
+            self.state.temperatures["nozzle_target"] = float(target)
+            self.state.temperatures["_nozzle_target_set_time"] = time.time()
+            logger.info(f"[{self.serial_number}] Tracking LEFT nozzle target locally: {target}°C")
+        return result
 
 
     def set_print_speed(self, mode: int) -> bool:
     def set_print_speed(self, mode: int) -> bool:
         """Set the print speed mode.
         """Set the print speed mode.

+ 44 - 13
frontend/src/components/control/TemperatureColumn.tsx

@@ -6,11 +6,16 @@ import type { PrinterStatus } from '../../api/client';
 interface Temperatures {
 interface Temperatures {
   bed?: number;
   bed?: number;
   bed_target?: number;
   bed_target?: number;
+  bed_heating?: boolean;
   nozzle?: number;
   nozzle?: number;
   nozzle_target?: number;
   nozzle_target?: number;
+  nozzle_heating?: boolean;
   nozzle_2?: number;
   nozzle_2?: number;
   nozzle_2_target?: number;
   nozzle_2_target?: number;
+  nozzle_2_heating?: boolean;
   chamber?: number;
   chamber?: number;
+  chamber_target?: number;
+  chamber_heating?: boolean;
 }
 }
 
 
 interface TemperatureColumnProps {
 interface TemperatureColumnProps {
@@ -64,9 +69,13 @@ export function TemperatureColumn({ printerId, status, nozzleCount, disabled = f
     if (editing === 'bed') {
     if (editing === 'bed') {
       bedMutation.mutate(target);
       bedMutation.mutate(target);
     } else if (editing === 'nozzle') {
     } else if (editing === 'nozzle') {
-      nozzleMutation.mutate({ target, nozzle: 0 });
+      // nozzle field = LEFT nozzle display
+      // H2D: LEFT is T1 (index 1), single nozzle: index 0
+      nozzleMutation.mutate({ target, nozzle: isDualNozzle ? 1 : 0 });
     } else if (editing === 'nozzle_2') {
     } else if (editing === 'nozzle_2') {
-      nozzleMutation.mutate({ target, nozzle: 1 });
+      // nozzle_2 field = RIGHT nozzle display
+      // H2D: RIGHT is T0/default (index 0)
+      nozzleMutation.mutate({ target, nozzle: 0 });
     }
     }
     cancelEditing();
     cancelEditing();
   };
   };
@@ -81,6 +90,12 @@ export function TemperatureColumn({ printerId, status, nozzleCount, disabled = f
 
 
   const isDisabled = !isConnected;
   const isDisabled = !isConnected;
 
 
+  // Use live heating state from MQTT
+  const isNozzleHeating = temps.nozzle_heating ?? false;
+  const isNozzle2Heating = temps.nozzle_2_heating ?? false;
+  const isBedHeating = temps.bed_heating ?? false;
+  const isChamberHeating = temps.chamber_heating ?? false;
+
   const renderTargetTemp = (
   const renderTargetTemp = (
     field: EditingField,
     field: EditingField,
     targetValue: number
     targetValue: number
@@ -88,15 +103,19 @@ export function TemperatureColumn({ printerId, status, nozzleCount, disabled = f
     if (editing === field) {
     if (editing === field) {
       return (
       return (
         <input
         <input
-          type="number"
+          type="text"
+          inputMode="numeric"
+          pattern="[0-9]*"
           value={editValue}
           value={editValue}
-          onChange={(e) => setEditValue(e.target.value)}
+          onChange={(e) => {
+            // Only allow numeric input
+            const val = e.target.value.replace(/[^0-9]/g, '');
+            setEditValue(val);
+          }}
           onBlur={submitEdit}
           onBlur={submitEdit}
           onKeyDown={handleKeyDown}
           onKeyDown={handleKeyDown}
           autoFocus
           autoFocus
-          className="w-12 text-sm bg-bambu-dark border border-bambu-green rounded px-1 py-0.5 text-white text-center"
-          min={0}
-          max={350}
+          className="w-12 text-sm bg-bambu-dark border border-bambu-green rounded px-1 py-0.5 text-white text-center [appearance:textfield]"
         />
         />
       );
       );
     }
     }
@@ -117,7 +136,11 @@ export function TemperatureColumn({ printerId, status, nozzleCount, disabled = f
       {/* Nozzle 1 (Left) */}
       {/* Nozzle 1 (Left) */}
       <div className="flex items-center gap-1.5">
       <div className="flex items-center gap-1.5">
         <div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
         <div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
-          <img src="/icons/hotend.svg" alt="" className="w-5 icon-theme" />
+          <img
+            src="/icons/hotend.svg"
+            alt=""
+            className={`w-5 ${isNozzleHeating ? 'icon-heating' : 'icon-theme'}`}
+          />
         </div>
         </div>
         {isDualNozzle && (
         {isDualNozzle && (
           <span className="text-[11px] font-semibold text-bambu-green bg-bambu-green/20 px-1.5 py-0.5 rounded min-w-[18px] text-center flex-shrink-0">
           <span className="text-[11px] font-semibold text-bambu-green bg-bambu-green/20 px-1.5 py-0.5 rounded min-w-[18px] text-center flex-shrink-0">
@@ -132,7 +155,11 @@ export function TemperatureColumn({ printerId, status, nozzleCount, disabled = f
       {isDualNozzle && (
       {isDualNozzle && (
         <div className="flex items-center gap-1.5">
         <div className="flex items-center gap-1.5">
           <div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
           <div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
-            <img src="/icons/hotend.svg" alt="" className="w-5 icon-theme" />
+            <img
+              src="/icons/hotend.svg"
+              alt=""
+              className={`w-5 ${isNozzle2Heating ? 'icon-heating' : 'icon-theme'}`}
+            />
           </div>
           </div>
           <span className="text-[11px] font-semibold text-bambu-green bg-bambu-green/20 px-1.5 py-0.5 rounded min-w-[18px] text-center flex-shrink-0">
           <span className="text-[11px] font-semibold text-bambu-green bg-bambu-green/20 px-1.5 py-0.5 rounded min-w-[18px] text-center flex-shrink-0">
             R
             R
@@ -145,21 +172,25 @@ export function TemperatureColumn({ printerId, status, nozzleCount, disabled = f
       {/* Bed */}
       {/* Bed */}
       <div className="flex items-center gap-1.5">
       <div className="flex items-center gap-1.5">
         <div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
         <div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
-          <img src="/icons/heatbed.svg" alt="" className="w-5 icon-theme" />
+          <img
+            src="/icons/heatbed.svg"
+            alt=""
+            className={`w-5 ${isBedHeating ? 'icon-heating' : 'icon-theme'}`}
+          />
         </div>
         </div>
         {isDualNozzle && <span className="min-w-[18px] flex-shrink-0" />}
         {isDualNozzle && <span className="min-w-[18px] flex-shrink-0" />}
         <span className="text-lg font-medium text-white">{Math.round(temps.bed ?? 0)}</span>
         <span className="text-lg font-medium text-white">{Math.round(temps.bed ?? 0)}</span>
         {renderTargetTemp('bed', temps.bed_target ?? 0)}
         {renderTargetTemp('bed', temps.bed_target ?? 0)}
       </div>
       </div>
 
 
-      {/* Chamber - read only */}
+      {/* Chamber - read only (target set by print file or display) */}
       <div className="flex items-center gap-1.5">
       <div className="flex items-center gap-1.5">
         <div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
         <div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
-          <img src="/icons/chamber.svg" alt="" className="w-5 icon-theme" />
+          <img src="/icons/chamber.svg" alt="" className={`w-5 ${isChamberHeating ? 'icon-heating' : 'icon-theme'}`} />
         </div>
         </div>
         {isDualNozzle && <span className="min-w-[18px] flex-shrink-0" />}
         {isDualNozzle && <span className="min-w-[18px] flex-shrink-0" />}
         <span className="text-lg font-medium text-white">{Math.round(temps.chamber ?? 0)}</span>
         <span className="text-lg font-medium text-white">{Math.round(temps.chamber ?? 0)}</span>
-        <span className="text-sm text-bambu-gray">°C</span>
+        <span className="text-sm text-bambu-gray">/{Math.round(temps.chamber_target ?? 0)} °C</span>
       </div>
       </div>
 
 
       {/* Air Condition - button */}
       {/* Air Condition - button */}

+ 6 - 0
frontend/src/index.css

@@ -108,6 +108,12 @@ body {
   opacity: 1;
   opacity: 1;
 }
 }
 
 
+/* Orange/red-colored icon for heating indicators */
+.icon-heating {
+  filter: invert(50%) sepia(100%) saturate(1000%) hue-rotate(360deg) brightness(100%) contrast(100%);
+  opacity: 1;
+}
+
 /* Jogpad theme styling - darken background in dark mode */
 /* Jogpad theme styling - darken background in dark mode */
 .jogpad-theme {
 .jogpad-theme {
   /* Light mode - normal */
   /* Light mode - normal */

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-1LVQBXOF.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CH7yvvuS.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-OlFdoeRK.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-C4OBhD7e.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-OlFdoeRK.css">
+    <script type="module" crossorigin src="/assets/index-1LVQBXOF.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CH7yvvuS.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов