Browse Source

Fix AMS slot mapping for A1 Mini (#364)

Add color-matching fallback for slot-to-tray mapping on printers that
don't provide the MQTT mapping field (snow encoding) or reject request
topic subscription. Matches 3MF filament slot colors against AMS tray
colors to resolve the correct physical tray. Returns no match when
colors are ambiguous (duplicate tray colors) to safely fall through
to existing fallbacks.

Mapping priority chain: print_cmd → MQTT snow → queue → color match →
tray_now → slot_id-1.
maziggy 3 months ago
parent
commit
ec751424b6

+ 1 - 1
CHANGELOG.md

@@ -17,7 +17,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **AMS-HT Snow Slot Mismatch Log Spam on H2D** — The snow-based tray_now disambiguation computed `snow_slot = -1` for AMS-HT trays (IDs 128-135), causing a "slot mismatch" debug log on every MQTT update even though the result was correct. Now correctly computes `snow_slot = 0` for AMS-HT single-slot units.
 - **AMS-HT Snow Slot Mismatch Log Spam on H2D** — The snow-based tray_now disambiguation computed `snow_slot = -1` for AMS-HT trays (IDs 128-135), causing a "slot mismatch" debug log on every MQTT update even though the result was correct. Now correctly computes `snow_slot = 0` for AMS-HT single-slot units.
 - **Color Tooltip Clipped Behind Adjacent Swatches** — Color swatch hover tooltips in the spool form were rendered behind neighboring swatches due to missing z-index on the hover state. Added `hover:z-20` and tooltip `z-20` classes.
 - **Color Tooltip Clipped Behind Adjacent Swatches** — Color swatch hover tooltips in the spool form were rendered behind neighboring swatches due to missing z-index on the hover state. Added `hover:z-20` and tooltip `z-20` classes.
 
 
-- **Usage Tracking Wrong Spool on Dual-Nozzle / Multi-AMS Printers** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2C, H2D Pro, and other dual-nozzle printers with multiple AMS units, the usage tracker attributed filament consumption to the wrong spools. The MQTT `mapping` field — a per-print array that maps slicer filament slots to physical AMS trays — was preserved in state but never parsed or used. The tracker fell back to `slot_id - 1` as the global tray ID, which is incorrect when AMS hardware IDs differ from sequential indices (e.g., AMS-HT units with ID 128). Now decodes the MQTT mapping field from its snow encoding (`ams_hw_id * 256 + local_slot`) into bambuddy global tray IDs and uses it as a universal mapping source — working for all printer models and all print sources (slicer, queue, reprint) without relying on `tray_now` disambiguation.
+- **Usage Tracking Wrong Spool on Dual-Nozzle / Multi-AMS Printers** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2C, H2D Pro, and other dual-nozzle printers with multiple AMS units, the usage tracker attributed filament consumption to the wrong spools. The MQTT `mapping` field — a per-print array that maps slicer filament slots to physical AMS trays — was preserved in state but never parsed or used. The tracker fell back to `slot_id - 1` as the global tray ID, which is incorrect when AMS hardware IDs differ from sequential indices (e.g., AMS-HT units with ID 128). Now decodes the MQTT mapping field from its snow encoding (`ams_hw_id * 256 + local_slot`) into bambuddy global tray IDs and uses it as a universal mapping source — working for all printer models and all print sources (slicer, queue, reprint) without relying on `tray_now` disambiguation. For printers that don't provide the MQTT mapping field (A1, A1 Mini, P1S, P2S), a color-matching fallback compares 3MF filament slot colors against AMS tray colors to resolve the correct slot-to-tray mapping. Gracefully returns no match when colors are ambiguous (duplicate tray colors) or unavailable.
 - **npm audit: suppress moderate ajv ReDoS finding** — Added `audit-level=high` to `frontend/.npmrc` so `npm audit` exits cleanly. The ajv@6 ReDoS (GHSA-2g4f-4pwh-qvx6) is a transitive dependency of eslint@9 with no patched v6 release; ajv@8 override breaks eslint. The vulnerability requires crafted `$data` schema input — not an attack vector in a linting config.
 - **npm audit: suppress moderate ajv ReDoS finding** — Added `audit-level=high` to `frontend/.npmrc` so `npm audit` exits cleanly. The ajv@6 ReDoS (GHSA-2g4f-4pwh-qvx6) is a transitive dependency of eslint@9 with no patched v6 release; ajv@8 override breaks eslint. The vulnerability requires crafted `$data` schema input — not an attack vector in a linting config.
 - **Spool Form Allows Empty Brand & Subtype** ([#417](https://github.com/maziggy/bambuddy/issues/417)) — The spool add/edit modal did not require Brand or Subtype fields, allowing spools to be saved without them. When such a spool was assigned to an AMS slot, the `tray_sub_brands` sent to the printer was incomplete (e.g., just "PETG" instead of "PETG Basic"), causing BambuStudio to not recognize the filament profile. Brand and Subtype are now mandatory fields with validation errors shown on submit.
 - **Spool Form Allows Empty Brand & Subtype** ([#417](https://github.com/maziggy/bambuddy/issues/417)) — The spool add/edit modal did not require Brand or Subtype fields, allowing spools to be saved without them. When such a spool was assigned to an AMS slot, the `tray_sub_brands` sent to the printer was incomplete (e.g., just "PETG" instead of "PETG Basic"), causing BambuStudio to not recognize the filament profile. Brand and Subtype are now mandatory fields with validation errors shown on submit.
 - **Open in Slicer Fails When Authentication Enabled** ([#421](https://github.com/maziggy/bambuddy/issues/421)) — The "Open in Slicer" buttons for BambuStudio and OrcaSlicer failed with "importing failed" when authentication was enabled. Slicer protocol handlers (`bambustudio://`, `orcaslicer://`) launch the slicer app which fetches the file via HTTP — but cannot send authentication headers, so the global auth middleware returned 401. Additionally, the URL format was wrong on Linux (used the macOS-only `bambustudioopen://` scheme instead of `bambustudio://open?file=`). Fixed with short-lived, single-use download tokens: the frontend fetches a token via an authenticated POST endpoint, then builds a `/dl/{token}/{filename}` URL that the slicer can access without auth headers. The token is validated server-side (5-minute expiry, single-use). Platform-specific URL formats now match the actual slicer source code: macOS uses `bambustudioopen://` with URL encoding, Windows/Linux use `bambustudio://open?file=`, and OrcaSlicer uses `orcaslicer://open?file=`.
 - **Open in Slicer Fails When Authentication Enabled** ([#421](https://github.com/maziggy/bambuddy/issues/421)) — The "Open in Slicer" buttons for BambuStudio and OrcaSlicer failed with "importing failed" when authentication was enabled. Slicer protocol handlers (`bambustudio://`, `orcaslicer://`) launch the slicer app which fetches the file via HTTP — but cannot send authentication headers, so the global auth middleware returned 401. Additionally, the URL format was wrong on Linux (used the macOS-only `bambustudioopen://` scheme instead of `bambustudio://open?file=`). Fixed with short-lived, single-use download tokens: the frontend fetches a token via an authenticated POST endpoint, then builds a `/dl/{token}/{filename}` URL that the slicer can access without auth headers. The token is validated server-side (5-minute expiry, single-use). Platform-specific URL formats now match the actual slicer source code: macOS uses `bambustudioopen://` with URL encoding, Windows/Linux use `bambustudio://open?file=`, and OrcaSlicer uses `orcaslicer://open?file=`.

+ 95 - 1
backend/app/services/usage_tracker.py

@@ -63,6 +63,90 @@ def _decode_mqtt_mapping(mapping_raw: list | None) -> list[int] | None:
     return result
     return result
 
 
 
 
+def _match_slots_by_color(
+    filament_usage: list[dict],
+    ams_raw: dict | list | None,
+) -> list[int] | None:
+    """Match 3MF filament slots to AMS trays by color.
+
+    Fallback mapping for printers that don't provide the MQTT mapping field
+    or request topic subscription (e.g. A1, A1 Mini, P1S, P2S).
+
+    Compares the 3MF slicer filament color (per slot) against each AMS tray's
+    color to find a unique match. Only returns a mapping if every used slot
+    matches exactly one tray (no ambiguity).
+
+    Args:
+        filament_usage: List of 3MF slot dicts with 'slot_id', 'color', 'type'
+        ams_raw: raw_data["ams"] dict or list from printer state
+
+    Returns:
+        List of global tray IDs indexed by slicer slot (0-based), or None.
+    """
+    if not filament_usage or not ams_raw:
+        return None
+
+    ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
+    if not ams_data:
+        return None
+
+    # Build map of normalized color → list of global tray IDs
+    color_to_trays: dict[str, list[int]] = {}
+    for ams_unit in ams_data:
+        ams_id = int(ams_unit.get("id", 0))
+        for tray in ams_unit.get("tray", []):
+            tray_id = int(tray.get("id", 0))
+            tray_color = tray.get("tray_color", "")
+            tray_type = tray.get("tray_type", "")
+            if not tray_color or not tray_type:
+                continue
+            # Normalize AMS color: strip alpha (last 2 chars), lowercase
+            norm = tray_color[:6].lower() if len(tray_color) >= 6 else tray_color.lower()
+            if ams_id >= 128:
+                global_id = ams_id  # AMS-HT
+            else:
+                global_id = ams_id * 4 + tray_id
+            color_to_trays.setdefault(norm, []).append(global_id)
+
+    if not color_to_trays:
+        return None
+
+    # Find max slot_id to size the result array
+    max_slot = max(u.get("slot_id", 0) for u in filament_usage)
+    if max_slot <= 0:
+        return None
+
+    result = [-1] * max_slot
+    used_trays: set[int] = set()
+
+    for usage in filament_usage:
+        slot_id = usage.get("slot_id", 0)
+        if slot_id <= 0:
+            continue
+        slot_color = usage.get("color", "").lstrip("#").lower()
+        if len(slot_color) < 6:
+            return None  # Can't match without a valid color
+
+        slot_color = slot_color[:6]  # Strip alpha if present
+        candidates = color_to_trays.get(slot_color, [])
+        # Filter out trays already claimed by another slot
+        available = [t for t in candidates if t not in used_trays]
+
+        if len(available) != 1:
+            # Ambiguous (multiple trays with same color) or no match
+            return None
+
+        result[slot_id - 1] = available[0]
+        used_trays.add(available[0])
+
+    # Only return if at least one valid mapping exists
+    if all(v < 0 for v in result):
+        return None
+
+    logger.info("[UsageTracker] Color-matched slot_to_tray: %s", result)
+    return result
+
+
 @dataclass
 @dataclass
 class PrintSession:
 class PrintSession:
     printer_id: int
     printer_id: int
@@ -392,13 +476,23 @@ async def _track_from_3mf(
             except (json.JSONDecodeError, TypeError):
             except (json.JSONDecodeError, TypeError):
                 pass
                 pass
 
 
+    # 4. Color-match 3MF filament slots to AMS trays (for printers without mapping field)
+    if not slot_to_tray:
+        state = printer_manager.get_status(printer_id)
+        raw_data = getattr(state, "raw_data", None) if state else None
+        if raw_data:
+            matched = _match_slots_by_color(filament_usage, raw_data.get("ams"))
+            if matched:
+                slot_to_tray = matched
+                mapping_source = "color_match"
+
     logger.info(
     logger.info(
         "[UsageTracker] 3MF: slot_to_tray=%s (source: %s)",
         "[UsageTracker] 3MF: slot_to_tray=%s (source: %s)",
         slot_to_tray,
         slot_to_tray,
         mapping_source or "none",
         mapping_source or "none",
     )
     )
 
 
-    # 3. For single-filament non-queue prints, use tray_now from printer state
+    # 5. For single-filament non-queue prints, use tray_now from printer state
     #    Priority: tray_now_at_start > current tray_now > last_loaded_tray > vt_tray check
     #    Priority: tray_now_at_start > current tray_now > last_loaded_tray > vt_tray check
     nonzero_slots = [u for u in filament_usage if u.get("used_g", 0) > 0]
     nonzero_slots = [u for u in filament_usage if u.get("used_g", 0) > 0]
     tray_now_override: int | None = None
     tray_now_override: int | None = None

+ 156 - 0
backend/tests/unit/test_usage_tracker.py

@@ -15,6 +15,7 @@ from backend.app.services.usage_tracker import (
     PrintSession,
     PrintSession,
     _active_sessions,
     _active_sessions,
     _decode_mqtt_mapping,
     _decode_mqtt_mapping,
+    _match_slots_by_color,
     _track_from_3mf,
     _track_from_3mf,
     on_print_complete,
     on_print_complete,
     on_print_start,
     on_print_start,
@@ -886,6 +887,161 @@ class TestDecodeMqttMapping:
         assert _decode_mqtt_mapping(["foo", 0]) == [-1, 0]
         assert _decode_mqtt_mapping(["foo", 0]) == [-1, 0]
 
 
 
 
+class TestMatchSlotsByColor:
+    """Tests for _match_slots_by_color() — color-based filament slot to AMS tray matching."""
+
+    def _ams(self, trays):
+        """Build AMS data from list of (ams_id, tray_id, color_hex, tray_type) tuples."""
+        units: dict[int, list] = {}
+        for ams_id, tray_id, color, tray_type in trays:
+            units.setdefault(ams_id, []).append({"id": tray_id, "tray_color": color, "tray_type": tray_type})
+        return [{"id": aid, "tray": t} for aid, t in units.items()]
+
+    def _usage(self, slots):
+        """Build filament_usage from list of (slot_id, color_hex) tuples."""
+        return [{"slot_id": sid, "used_g": 10.0, "type": "PLA", "color": color} for sid, color in slots]
+
+    def test_none_inputs(self):
+        assert _match_slots_by_color(None, None) is None
+        assert _match_slots_by_color([], None) is None
+        assert _match_slots_by_color(None, {"ams": []}) is None
+
+    def test_empty_ams(self):
+        usage = self._usage([(1, "#FF0000")])
+        assert _match_slots_by_color(usage, {"ams": []}) is None
+
+    def test_single_slot_single_tray(self):
+        """One 3MF slot matches one AMS tray by color."""
+        ams = self._ams([(0, 0, "FF0000FF", "PLA")])
+        usage = self._usage([(1, "#FF0000")])
+        assert _match_slots_by_color(usage, {"ams": ams}) == [0]
+
+    def test_a1_mini_three_colors(self):
+        """A1 Mini: 3 slots match 3 distinct AMS trays."""
+        ams = self._ams(
+            [
+                (0, 0, "FF0000FF", "PLA"),  # Red
+                (0, 1, "00FF00FF", "PLA"),  # Green
+                (0, 2, "0000FFFF", "PLA"),  # Blue
+            ]
+        )
+        usage = self._usage([(1, "#FF0000"), (2, "#00FF00"), (3, "#0000FF")])
+        assert _match_slots_by_color(usage, {"ams": ams}) == [0, 1, 2]
+
+    def test_dual_ams_p2s_like(self):
+        """P2S with dual AMS: slots from second AMS unit."""
+        ams = self._ams(
+            [
+                (0, 0, "AAAAAAFF", "PLA"),
+                (0, 1, "BBBBBBFF", "PLA"),
+                (1, 0, "CC0000FF", "PETG"),  # global_id=4
+                (1, 1, "00CC00FF", "PETG"),  # global_id=5
+            ]
+        )
+        usage = self._usage([(1, "#CC0000"), (2, "#00CC00")])
+        assert _match_slots_by_color(usage, {"ams": ams}) == [4, 5]
+
+    def test_ams_ht_global_id(self):
+        """AMS-HT (ams_id >= 128) uses raw ams_id as global tray ID."""
+        ams = self._ams(
+            [
+                (0, 0, "FF0000FF", "PLA"),
+                (128, 0, "0000FFFF", "PLA"),  # AMS-HT → global_id=128
+            ]
+        )
+        usage = self._usage([(1, "#FF0000"), (2, "#0000FF")])
+        assert _match_slots_by_color(usage, {"ams": ams}) == [0, 128]
+
+    def test_ambiguous_same_color_returns_none(self):
+        """Two trays with the same color → ambiguous → None."""
+        ams = self._ams(
+            [
+                (0, 0, "FF0000FF", "PLA"),
+                (0, 1, "FF0000FF", "PLA"),  # Same red
+            ]
+        )
+        usage = self._usage([(1, "#FF0000")])
+        assert _match_slots_by_color(usage, {"ams": ams}) is None
+
+    def test_no_matching_color_returns_none(self):
+        """3MF slot color not found in any AMS tray → None."""
+        ams = self._ams([(0, 0, "00FF00FF", "PLA")])
+        usage = self._usage([(1, "#FF0000")])  # Red, but AMS has green
+        assert _match_slots_by_color(usage, {"ams": ams}) is None
+
+    def test_color_normalization_strips_alpha(self):
+        """AMS colors (RRGGBBAA) and 3MF colors (#RRGGBB) match after normalization."""
+        ams = self._ams([(0, 0, "AABBCC80", "PLA")])  # 8-char with alpha
+        usage = self._usage([(1, "#AABBCC")])  # 6-char with #
+        assert _match_slots_by_color(usage, {"ams": ams}) == [0]
+
+    def test_case_insensitive(self):
+        """Color matching is case-insensitive."""
+        ams = self._ams([(0, 0, "aaBBccFF", "PLA")])
+        usage = self._usage([(1, "#AAbbCC")])
+        assert _match_slots_by_color(usage, {"ams": ams}) == [0]
+
+    def test_empty_tray_color_skipped(self):
+        """Trays with empty color are skipped (not matched)."""
+        ams = self._ams(
+            [
+                (0, 0, "", "PLA"),
+                (0, 1, "FF0000FF", "PLA"),
+            ]
+        )
+        usage = self._usage([(1, "#FF0000")])
+        assert _match_slots_by_color(usage, {"ams": ams}) == [1]
+
+    def test_empty_tray_type_skipped(self):
+        """Trays with empty tray_type are skipped (unloaded slot)."""
+        ams = self._ams(
+            [
+                (0, 0, "FF0000FF", ""),  # Empty slot
+                (0, 1, "FF0000FF", "PLA"),  # Loaded slot
+            ]
+        )
+        usage = self._usage([(1, "#FF0000")])
+        assert _match_slots_by_color(usage, {"ams": ams}) == [1]
+
+    def test_short_slot_color_returns_none(self):
+        """3MF slot with color < 6 chars → can't match → None."""
+        ams = self._ams([(0, 0, "FF0000FF", "PLA")])
+        usage = [{"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": "#FFF"}]
+        assert _match_slots_by_color(usage, {"ams": ams}) is None
+
+    def test_slot_id_zero_skipped(self):
+        """Slots with slot_id=0 are skipped."""
+        ams = self._ams([(0, 0, "FF0000FF", "PLA")])
+        usage = [{"slot_id": 0, "used_g": 10.0, "type": "PLA", "color": "#FF0000"}]
+        assert _match_slots_by_color(usage, {"ams": ams}) is None
+
+    def test_ams_data_as_list(self):
+        """Handles ams_raw as a plain list (some printer models)."""
+        ams_list = [{"id": 0, "tray": [{"id": 0, "tray_color": "FF0000FF", "tray_type": "PLA"}]}]
+        usage = self._usage([(1, "#FF0000")])
+        assert _match_slots_by_color(usage, ams_list) == [0]
+
+    def test_same_color_two_trays_disambiguated_by_usage(self):
+        """Two trays same color, two slots same color → unique assignment via used_trays tracking."""
+        ams = self._ams(
+            [
+                (0, 0, "FF0000FF", "PLA"),
+                (0, 1, "FF0000FF", "PLA"),
+            ]
+        )
+        # Two slots both wanting red — first gets tray 0, second gets tray 1? No.
+        # When first slot takes the only available, second has 1 left → should work
+        usage = self._usage([(1, "#FF0000"), (2, "#FF0000")])
+        # First slot: candidates=[0,1], available=[0,1], len!=1 → None
+        assert _match_slots_by_color(usage, {"ams": ams}) is None
+
+    def test_dict_wrapper_with_ams_key(self):
+        """Standard dict format with 'ams' key."""
+        ams_data = {"ams": [{"id": 0, "tray": [{"id": 0, "tray_color": "00FF00FF", "tray_type": "PLA"}]}]}
+        usage = self._usage([(1, "#00FF00")])
+        assert _match_slots_by_color(usage, ams_data) == [0]
+
+
 class TestMqttMappingIntegration:
 class TestMqttMappingIntegration:
     """Integration tests: MQTT mapping field used in _track_from_3mf."""
     """Integration tests: MQTT mapping field used in _track_from_3mf."""
 
 

+ 4 - 4
frontend/src/__tests__/utils/slicer.test.ts

@@ -50,8 +50,8 @@ describe('slicer utility', () => {
       openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');
       openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');
 
 
       expect(appendSpy).toHaveBeenCalled();
       expect(appendSpy).toHaveBeenCalled();
-      expect(createdLink.href).toContain('bambustudio://');
-      expect(createdLink.href).toContain(encodeURIComponent('http://localhost:8000/file.3mf'));
+      expect(createdLink.href).toContain('bambustudio://open?file=');
+      expect(createdLink.href).toContain('http://localhost:8000/file.3mf');
       expect(clickSpy).toHaveBeenCalled();
       expect(clickSpy).toHaveBeenCalled();
       expect(removeSpy).toHaveBeenCalled();
       expect(removeSpy).toHaveBeenCalled();
     });
     });
@@ -63,11 +63,11 @@ describe('slicer utility', () => {
       expect(createdLink.href).toContain('bambustudioopen://');
       expect(createdLink.href).toContain('bambustudioopen://');
     });
     });
 
 
-    it('uses bambustudioopen:// protocol on Linux for bambu_studio', () => {
+    it('uses bambustudio://open?file= on Linux for bambu_studio', () => {
       vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (X11; Linux x86_64)');
       vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (X11; Linux x86_64)');
       openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');
       openInSlicer('http://localhost:8000/file.3mf', 'bambu_studio');
 
 
-      expect(createdLink.href).toContain('bambustudioopen://');
+      expect(createdLink.href).toContain('bambustudio://open?file=');
     });
     });
 
 
     it('uses orcaslicer:// protocol for orcaslicer on all platforms', () => {
     it('uses orcaslicer:// protocol for orcaslicer on all platforms', () => {

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DqAJp9_u.js


Some files were not shown because too many files changed in this diff