Wired temperature controlers

This commit is contained in:
Martin Ziegler
2025-12-03 15:17:34 +01:00
parent b6af768482
commit 0ab0707a87
7 changed files with 192 additions and 67 deletions
+138 -50
View File
@@ -645,27 +645,64 @@ class BambuMQTTClient:
temps["bed"] = float(data["bed_temper"])
if "bed_target_temper" in data:
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:
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:
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:
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)
try:
if "info" in data and isinstance(data["info"], dict):
@@ -680,37 +717,85 @@ class BambuMQTTClient:
extruder_data = device.get("extruder", {})
extruder_info = extruder_data.get("info", [])
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"]
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]:
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
ctc_data = device.get("ctc", {})
ctc_info = ctc_data.get("info", {})
if "temp" in ctc_info and "chamber" not in temps:
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:
logger.warning(f"[{self.serial_number}] Error parsing H2D temperatures: {e}")
if temps:
@@ -1650,7 +1735,8 @@ class BambuMQTTClient:
"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]}...")
return True
@@ -1663,8 +1749,6 @@ class BambuMQTTClient:
Returns:
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}")
def set_nozzle_temperature(self, target: int, nozzle: int = 0) -> bool:
@@ -1672,17 +1756,21 @@ class BambuMQTTClient:
Args:
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:
True if command was sent, False otherwise
"""
# 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:
"""Set the print speed mode.
@@ -6,11 +6,16 @@ import type { PrinterStatus } from '../../api/client';
interface Temperatures {
bed?: number;
bed_target?: number;
bed_heating?: boolean;
nozzle?: number;
nozzle_target?: number;
nozzle_heating?: boolean;
nozzle_2?: number;
nozzle_2_target?: number;
nozzle_2_heating?: boolean;
chamber?: number;
chamber_target?: number;
chamber_heating?: boolean;
}
interface TemperatureColumnProps {
@@ -64,9 +69,13 @@ export function TemperatureColumn({ printerId, status, nozzleCount, disabled = f
if (editing === 'bed') {
bedMutation.mutate(target);
} 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') {
nozzleMutation.mutate({ target, nozzle: 1 });
// nozzle_2 field = RIGHT nozzle display
// H2D: RIGHT is T0/default (index 0)
nozzleMutation.mutate({ target, nozzle: 0 });
}
cancelEditing();
};
@@ -81,6 +90,12 @@ export function TemperatureColumn({ printerId, status, nozzleCount, disabled = f
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 = (
field: EditingField,
targetValue: number
@@ -88,15 +103,19 @@ export function TemperatureColumn({ printerId, status, nozzleCount, disabled = f
if (editing === field) {
return (
<input
type="number"
type="text"
inputMode="numeric"
pattern="[0-9]*"
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}
onKeyDown={handleKeyDown}
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) */}
<div className="flex items-center gap-1.5">
<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>
{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">
@@ -132,7 +155,11 @@ export function TemperatureColumn({ printerId, status, nozzleCount, disabled = f
{isDualNozzle && (
<div className="flex items-center gap-1.5">
<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>
<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
@@ -145,21 +172,25 @@ export function TemperatureColumn({ printerId, status, nozzleCount, disabled = f
{/* Bed */}
<div className="flex items-center gap-1.5">
<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>
{isDualNozzle && <span className="min-w-[18px] flex-shrink-0" />}
<span className="text-lg font-medium text-white">{Math.round(temps.bed ?? 0)}</span>
{renderTargetTemp('bed', temps.bed_target ?? 0)}
</div>
{/* Chamber - read only */}
{/* Chamber - read only (target set by print file or display) */}
<div className="flex items-center gap-1.5">
<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>
{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-sm text-bambu-gray">°C</span>
<span className="text-sm text-bambu-gray">/{Math.round(temps.chamber_target ?? 0)} °C</span>
</div>
{/* Air Condition - button */}
+6
View File
@@ -108,6 +108,12 @@ body {
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 {
/* Light mode - normal */
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
@@ -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-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>
<body>
<div id="root"></div>