Browse Source

feat(ams): gate humidity/temperature alarms on AMS-has-filament (#1619)

  The hourly AMS sensor recorder dispatched humidity and temperature alarms
  for every unit above threshold without checking whether the unit was
  actually loaded. Empty AMS units still report ambient readings, so users
  with one loaded + one empty AMS got useful alarms for the loaded one and
  hourly noise for the empty one. Disabling the whole alarm category killed
  both — not a real choice.

  New _ams_has_filament helper inspects tray_exist_bits (hex bitmap, "0" =
  empty) with fallback to the tray array's tray_type strings for shapes
  where the bitmap is missing. The recorder gates the alarm dispatch on
  this check per-AMS-unit, so a multi-AMS printer with one loaded + one
  empty still alarms on the loaded one.

  Sensor history still records regardless of the gate so the System page
  humidity charts stay continuous — only the outbound notification is
  suppressed. 9 unit tests cover the bitmap-zero case, bitmap-missing
  fallback, garbage/blank/int bitmap edges, and defensive malformed tray.
maziggy 3 tháng trước cách đây
mục cha
commit
51730a7bf1
3 tập tin đã thay đổi với 123 bổ sung0 xóa
  1. 3 0
      CHANGELOG.md
  2. 38 0
      backend/app/main.py
  3. 82 0
      backend/tests/unit/test_ams_alarm_gating.py

+ 3 - 0
CHANGELOG.md

@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
 
 ## [0.2.5b1] - Unreleased
 
+### Changed
+- **Empty AMS units no longer trigger hourly humidity/temperature notifications (#1619)** — The hourly AMS sensor recorder in `backend/app/main.py::record_ams_history` fanned out humidity and temperature alarms for every AMS unit above threshold without checking whether the unit was actually loaded with filament. Empty AMS units still report ambient sensor readings, so users with one loaded AMS and one empty one got useful alarms for the loaded unit and steady noise for the empty one every hour. The reporter's workaround (disable all AMS humidity notifications) also killed the useful alarms — not a real choice. New `_ams_has_filament(ams_data)` helper inspects the firmware-reported `tray_exist_bits` hex bitmap (one bit per tray slot, `"0"` / `"00"` = empty unit) with a fallback to the `tray` array's `tray_type` strings for early-pushall shapes where the bitmap is missing. The recorder gates the alarm dispatch on this check per-AMS-unit, so a multi-AMS printer with one loaded + one empty still alarms on the loaded one. **Sensor history still records regardless of the gate** so the System page humidity/temperature charts stay continuous — the only thing the gate suppresses is the outbound notification. 9 unit tests in `test_ams_alarm_gating.py` cover the bitmap-zero-is-empty case, single/multi/all-loaded variants, the `tray_exist_bits` missing → tray-array fallback, garbage bitmap → fallback, blank bitmap → fallback, non-string bitmap → fallback (Bambu sometimes sends `int`), whitespace-only `tray_type` not counting as loaded, and defensive non-dict tray entries.
+
 ### Added
 - **VP MQTT bridge surfaces why `net.info[].ip` rewrite didn't arm (#1429 defensive)** — `MQTTBridge._refresh_ip_encoding` had 4 silent early-return paths (`target_client is None`, `printer client has no ip_address yet`, `no host interface shares a subnet with printer IP X and bind_address is 0.0.0.0/empty`, `invalid IPv4 …`). When the rewrite silently no-op'd on a user's setup, the only signal was the absence of the `MQTT bridge IP encoding armed` INFO line — diagnosing which path was firing meant grepping the source. Each path now emits one `MQTT bridge IP encoding NOT armed: <specific reason>` INFO line; the message names the actual failure (target IP, the missing-interface case, etc.). Throttled via a `_not_armed_reason` dedup field so an idle unarmed bridge doesn't spam one line per 30s refresh tick — only state changes log. Cleared on successful arm so a regression (e.g. printer client unbinds) re-emits the diagnostic. 5 new tests in `TestNotArmedDiagnosticLogging` pin each path's specific reason text, the once-per-state-change throttle, and the arm-clears-dedup behaviour. **Not a fix for #1429 itself** — the bridge logic is unchanged; this just turns the silent failure into visible signal so the next "fix didn't work for me" report can be triaged in one round-trip instead of multiple.
 

+ 38 - 0
backend/app/main.py

@@ -4444,6 +4444,34 @@ _ams_alarm_cooldown: dict[str, datetime] = {}
 AMS_ALARM_COOLDOWN_MINUTES = 60  # Don't send same alarm more than once per hour
 
 
+def _ams_has_filament(ams_data: dict) -> bool:
+    """True if this AMS unit has at least one tray slot holding filament.
+
+    Bambu firmware reports loaded slots via `tray_exist_bits`, a per-AMS hex
+    bitmap (one bit per tray slot — bit set = spool present). Empty AMS units
+    still report sensor readings, but those readings are ambient and not
+    actionable: no filament to dry, no humidity to push down. #1619 — gate
+    humidity/temperature alarms on this check so empty units don't generate
+    hourly noise. Sensor history still records regardless so the UI charts
+    stay continuous.
+
+    Fallback path inspects the `tray` array's `tray_type` fields for setups
+    where `tray_exist_bits` is missing (some early-connection pushall shapes).
+    """
+    bits = ams_data.get("tray_exist_bits")
+    if isinstance(bits, str) and bits.strip():
+        try:
+            return int(bits, 16) > 0
+        except ValueError:
+            pass
+    trays = ams_data.get("tray")
+    if isinstance(trays, list):
+        return any(
+            isinstance(t, dict) and isinstance(t.get("tray_type"), str) and t["tray_type"].strip() for t in trays
+        )
+    return False
+
+
 async def record_ams_history():
     """Background task to record AMS humidity and temperature data."""
     logger = logging.getLogger(__name__)
@@ -4541,6 +4569,16 @@ async def record_ams_history():
                         else:
                             ams_label = f"AMS-{chr(65 + ams_id)}"
 
+                        # Skip alarm dispatch for empty AMS units — humidity /
+                        # temperature readings are ambient with no filament to
+                        # protect, and the hourly notification just becomes
+                        # noise. Sensor history was already recorded above so
+                        # the UI charts stay continuous (#1619). Per-AMS check
+                        # so a multi-AMS setup with one loaded + one empty
+                        # still alarms on the loaded unit.
+                        if not _ams_has_filament(ams_data):
+                            continue
+
                         # Check humidity alarm (only if above threshold)
                         if humidity is not None and humidity > humidity_threshold:
                             cooldown_key = f"{printer.id}:{ams_id}:humidity"

+ 82 - 0
backend/tests/unit/test_ams_alarm_gating.py

@@ -0,0 +1,82 @@
+"""Tests for the empty-AMS alarm gate (#1619).
+
+Empty AMS units still emit humidity/temperature sensor readings, but those
+readings are ambient and not actionable — there's no filament to dry. Without
+the gate every empty AMS spammed an hourly alarm. ``_ams_has_filament``
+inspects the firmware-reported ``tray_exist_bits`` bitmap (fallback: ``tray``
+array's ``tray_type`` strings) so the alarm dispatch in ``record_ams_history``
+can skip empty units while still alarming on loaded ones in the same printer.
+"""
+
+from backend.app.main import _ams_has_filament
+
+
+class TestAmsHasFilament:
+    def test_tray_exist_bits_zero_means_empty(self):
+        assert _ams_has_filament({"tray_exist_bits": "0"}) is False
+        # Real firmware sometimes pads with extra zeros or prefixes; all
+        # parseable forms of zero should resolve to "empty".
+        assert _ams_has_filament({"tray_exist_bits": "00"}) is False
+        assert _ams_has_filament({"tray_exist_bits": "0x0"}) is False
+
+    def test_tray_exist_bits_nonzero_means_loaded(self):
+        # Single tray loaded — e.g. AMS-Lite or AMS-HT.
+        assert _ams_has_filament({"tray_exist_bits": "1"}) is True
+        # Four-slot AMS with all slots full (bitmap 0xf == 0b1111).
+        assert _ams_has_filament({"tray_exist_bits": "f"}) is True
+        # Mixed — 0xa == 0b1010, two slots loaded.
+        assert _ams_has_filament({"tray_exist_bits": "a"}) is True
+        # The exact bitmap seen in #1622 / #1602 logs.
+        assert _ams_has_filament({"tray_exist_bits": "ed"}) is True
+
+    def test_falls_back_to_tray_array_when_bits_missing(self):
+        # Empty tray_type strings across the whole tray array → empty AMS.
+        ams_empty = {
+            "tray": [
+                {"id": 0, "tray_type": ""},
+                {"id": 1, "tray_type": ""},
+            ]
+        }
+        assert _ams_has_filament(ams_empty) is False
+        # Any non-empty tray_type → loaded AMS.
+        ams_loaded = {
+            "tray": [
+                {"id": 0, "tray_type": ""},
+                {"id": 1, "tray_type": "PLA"},
+            ]
+        }
+        assert _ams_has_filament(ams_loaded) is True
+
+    def test_missing_both_signals_returns_false(self):
+        # No tray_exist_bits AND no tray array — early-pushall shape; we
+        # treat it as "no info → don't alarm" rather than guessing loaded.
+        assert _ams_has_filament({}) is False
+
+    def test_unparseable_bitmap_falls_back_to_tray_array(self):
+        # Garbage in tray_exist_bits — must not raise and must fall through
+        # to the tray array check.
+        loaded = {"tray_exist_bits": "garbage", "tray": [{"id": 0, "tray_type": "PETG"}]}
+        assert _ams_has_filament(loaded) is True
+        empty = {"tray_exist_bits": "garbage", "tray": []}
+        assert _ams_has_filament(empty) is False
+
+    def test_empty_bits_string_falls_back_to_tray_array(self):
+        # Some pre-handshake pushall shapes set the field but leave it blank.
+        loaded = {"tray_exist_bits": "", "tray": [{"id": 0, "tray_type": "ABS"}]}
+        assert _ams_has_filament(loaded) is True
+
+    def test_whitespace_tray_type_is_not_loaded(self):
+        # A tray_type that's all whitespace doesn't count as a real material.
+        assert _ams_has_filament({"tray": [{"id": 0, "tray_type": "   "}]}) is False
+
+    def test_non_dict_tray_entries_are_skipped(self):
+        # Defensive: malformed tray array shouldn't crash the helper.
+        assert _ams_has_filament({"tray": [None, "junk", 42]}) is False
+
+    def test_non_string_bits_falls_back(self):
+        # Some MQTT shapes send tray_exist_bits as int; we only parse strings,
+        # so an int falls through to the tray array.
+        loaded = {"tray_exist_bits": 0xED, "tray": [{"id": 0, "tray_type": "PLA"}]}
+        assert _ams_has_filament(loaded) is True
+        empty_int = {"tray_exist_bits": 0xED}  # no tray array, int ignored
+        assert _ams_has_filament(empty_int) is False