diff --git a/CHANGELOG.md b/CHANGELOG.md index a0450ce71..9d2e644a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,9 +23,37 @@ All notable changes to Bambuddy will be documented in this file. - Model change automatically restarts the virtual printer (no manual disable/enable needed) - Models sorted alphabetically in dropdown - **Pending upload delete confirmation** - Confirmation modal when discarding pending uploads in queue review mode +- **Tasmota admin link** - Direct link to Tasmota web interface on smart plug cards: + - Auto-login using stored credentials (when available) + - Opens in new tab for quick access to plug settings +- **Debug logging** - Added debug logging for printer hour counter and AMS slot mapping +- **Demo video recorder** - Playwright-based tool for recording demo videos: + - Automated walkthrough of all pages + - Outputs WebM format, easy conversion to MP4 + - Located in `demo-video/` directory ### Fixed -- **Camera stream reconnection** - Improved detection of stuck camera streams with automatic reconnection +- **Camera stream reconnection** - Improved detection of stuck camera streams with automatic reconnection: + - Periodic stall detection checks every 5 seconds + - Automatic reconnection when stream stops receiving frames + - New `/api/v1/printers/{id}/camera/status` endpoint for stream health monitoring +- **Spoolman sync** - Fixed sync issues with Spoolman integration: + - Now only matches Bambu Lab vendor filaments when syncing + - Prevents incorrect matching with third-party filaments by color alone + - Improved filament matching accuracy +- **Archive card context menu** - Fixed context menu positioning issues (#46) +- **Printer card cover image** - Fixed wrong cover image displayed for multi-plate print files +- **Skip objects modal** - Fixed object ID markers not correctly positioned over build plate preview: + - Now uses `bbox_all` from plate metadata for accurate coordinate mapping + - Markers correctly position relative to the actual object bounds in the preview image + - Works correctly for multi-plate projects +- **Active AMS slot display (H2D)** - Fixed incorrect slot display on H2D printers with multiple AMS units: + - Now parses `snow` field from `device.extruder.info` which contains actual AMS ID + - Previously picked first AMS on the extruder, causing wrong display when multiple AMS connected + - Example: Switching from B2 to C1 now correctly shows C1 instead of A1 +- **Spoolman link function** - Improved "Link to Spoolman" in AMS slot detail modal +- **GCode viewer** - Minor improvements to GCode visualization +- **Cover image retrieval** - Improved reliability of cover image extraction - **Virtual printer SSDP model codes** - Corrected model codes for slicer compatibility: - C11=P1P, C12=P1S (were incorrectly swapped) - N7=P2S (was incorrectly using C13 which is X1E) diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 55045a49d..12f13ef91 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -144,6 +144,9 @@ class PrinterState: ams_mapping: list = field(default_factory=list) # Per-AMS extruder map: {ams_id: extruder_id} where 0=right, 1=left ams_extruder_map: dict = field(default_factory=dict) + # 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) # 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} @@ -771,71 +774,73 @@ class BambuMQTTClient: # Clear pending target since it's stale self.state.pending_tray_target = None else: - # No pending target - use active_extruder + ams_extruder_map to disambiguate - # Find which AMS is connected to the active extruder + # No pending target - use h2d_extruder_snow for accurate disambiguation + # H2D sends snow field in device.extruder.info with AMS ID in high byte active_ext = self.state.active_extruder # 0=right, 1=left - ams_map = self.state.ams_extruder_map # {ams_id: extruder_id} - # First, check if current tray_now is already a valid global ID - # that matches this slot AND is connected to the active extruder - current_tray = self.state.tray_now - if current_tray > 3 and current_tray != 255 and (current_tray % 4) == parsed_tray_now: - current_ams = current_tray // 4 - current_ams_extruder = ams_map.get(str(current_ams)) - if current_ams_extruder == active_ext: - # Current tray is valid, matches slot, and on correct extruder - keep it - logger.debug( - f"[{self.serial_number}] H2D tray_now: keeping existing global ID {current_tray} " - f"(slot {parsed_tray_now}, AMS {current_ams} on extruder {active_ext})" - ) - # Don't update tray_now - it's already correct - pass - else: - # Current AMS is on wrong extruder - need to find correct AMS - active_ams_id = None - for ams_id_str, ext_id in ams_map.items(): - if ext_id == active_ext: - try: - active_ams_id = int(ams_id_str) - except ValueError: - pass - break - if active_ams_id is not None: - global_tray_id = active_ams_id * 4 + parsed_tray_now + # Best source: use snow value from device.extruder.info if available + snow_tray = self.state.h2d_extruder_snow.get(active_ext) + if snow_tray is not None and snow_tray != 255: + # snow_tray is already normalized to global ID + # Verify the slot matches what we see in tray_now + snow_slot = snow_tray % 4 if snow_tray < 128 else -1 + if snow_slot == parsed_tray_now: + if self.state.tray_now != snow_tray: logger.info( - f"[{self.serial_number}] H2D tray_now disambiguation: " - f"slot {parsed_tray_now} + active_extruder {active_ext} -> AMS {active_ams_id} -> global ID {global_tray_id}" + f"[{self.serial_number}] H2D tray_now from snow: " + f"extruder[{active_ext}] snow={snow_tray} (slot {snow_slot})" ) - self.state.tray_now = global_tray_id - else: - logger.warning( - f"[{self.serial_number}] H2D tray_now: no ams_extruder_map for active_extruder {active_ext}" - ) - self.state.tray_now = parsed_tray_now + self.state.tray_now = snow_tray + else: + # Slot mismatch - snow field may not have updated yet, trust snow + logger.debug( + f"[{self.serial_number}] H2D tray_now: ams.tray_now slot {parsed_tray_now} " + f"!= snow slot {snow_slot}, using snow value {snow_tray}" + ) + self.state.tray_now = snow_tray else: - # No valid current tray - find an AMS connected to the active extruder - active_ams_id = None + # Fallback: snow not available, use ams_extruder_map (less reliable) + ams_map = self.state.ams_extruder_map + # Find ALL AMS units on the active extruder + ams_on_extruder = [] for ams_id_str, ext_id in ams_map.items(): if ext_id == active_ext: try: - active_ams_id = int(ams_id_str) + ams_on_extruder.append(int(ams_id_str)) except ValueError: pass - break - if active_ams_id is not None: - # Calculate global tray ID using the active AMS + if len(ams_on_extruder) == 1: + # Single AMS on this extruder - unambiguous + active_ams_id = ams_on_extruder[0] global_tray_id = active_ams_id * 4 + parsed_tray_now logger.info( - f"[{self.serial_number}] H2D tray_now disambiguation: " - f"slot {parsed_tray_now} + active_extruder {active_ext} -> AMS {active_ams_id} -> global ID {global_tray_id}" + f"[{self.serial_number}] H2D tray_now fallback: " + f"slot {parsed_tray_now} + single AMS {active_ams_id} -> global ID {global_tray_id}" ) self.state.tray_now = global_tray_id + elif len(ams_on_extruder) > 1: + # Multiple AMS on this extruder - keep current if valid, else use slot as-is + current_tray = self.state.tray_now + current_ams = current_tray // 4 if current_tray < 128 else -1 + if current_ams in ams_on_extruder and (current_tray % 4) == parsed_tray_now: + # Current is valid and matches slot - keep it + logger.debug( + f"[{self.serial_number}] H2D tray_now: multiple AMS {ams_on_extruder}, " + f"keeping current {current_tray} (matches slot {parsed_tray_now})" + ) + else: + # Can't disambiguate - use slot as-is (will be wrong for non-first AMS) + logger.warning( + f"[{self.serial_number}] H2D tray_now: multiple AMS {ams_on_extruder} on extruder {active_ext}, " + f"no snow field, using slot {parsed_tray_now} (may be incorrect)" + ) + self.state.tray_now = parsed_tray_now else: - # Fallback: use slot as-is + # No AMS on this extruder - use slot as-is logger.warning( - f"[{self.serial_number}] H2D tray_now: no ams_extruder_map for active_extruder {active_ext}, " - f"using slot {parsed_tray_now} as global ID (may be incorrect for multi-AMS)" + f"[{self.serial_number}] H2D tray_now: no AMS on extruder {active_ext}, " + f"using slot {parsed_tray_now}" ) self.state.tray_now = parsed_tray_now else: @@ -1247,6 +1252,45 @@ class BambuMQTTClient: if not respect_local_target: temps["nozzle_target"] = 0.0 temps["nozzle_heating"] = False # Direct = not heating + # Parse H2D snow field (slot now) for accurate tray_now disambiguation + # snow encodes AMS ID in high byte: ams_id = snow >> 8, slot = snow & 0xFF + if has_h2d_extruder_info: + for ext_info in extruder_info: + ext_id = ext_info.get("id") + snow = ext_info.get("snow") + if ext_id is not None and snow is not None and ext_id <= 1: + # Normalize H2D snow value to global tray ID + ams_id = snow >> 8 + slot = snow & 0xFF + if 0 <= ams_id <= 3: + # Regular AMS slot + global_tray = ams_id * 4 + (slot & 0x03) + old_val = self.state.h2d_extruder_snow.get(ext_id) + if old_val != global_tray: + logger.info( + f"[{self.serial_number}] H2D extruder[{ext_id}] snow: " + f"raw={snow} (AMS {ams_id} slot {slot}) -> global tray {global_tray}" + ) + self.state.h2d_extruder_snow[ext_id] = global_tray + elif ams_id == 254 or ams_id == 255: + # External spool or unloaded + normalized = 254 if slot != 255 else 255 + old_val = self.state.h2d_extruder_snow.get(ext_id) + if old_val != normalized: + logger.info( + f"[{self.serial_number}] H2D extruder[{ext_id}] snow: " + f"raw={snow} -> {'external' if normalized == 254 else 'unloaded'}" + ) + self.state.h2d_extruder_snow[ext_id] = normalized + elif 128 <= ams_id <= 135: + # External spool with hub mapping + old_val = self.state.h2d_extruder_snow.get(ext_id) + if old_val != ams_id: + logger.info( + f"[{self.serial_number}] H2D extruder[{ext_id}] snow: " + f"raw={snow} -> external hub {ams_id}" + ) + self.state.h2d_extruder_snow[ext_id] = ams_id # 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", {})