| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377 |
- """Tests for the gates that hold back AMS humidity / temperature alarms.
- Two independent gates, both sitting in ``record_ams_history``'s dispatch: the
- empty-AMS gate (#1619) documented below, and the drying gate (#1802) that stops
- the temperature alarm firing throughout a drying cycle and the cool-down after
- it.
- 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 datetime import datetime, timedelta, timezone
- from backend.app.main import _ams_has_filament, _resolve_temp_alarm_threshold
- from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
- 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
- class TestIsDryingActive:
- """The two firmware signals that mean "a drying cycle is running" (#1802)."""
- def test_countdown_running_is_active(self):
- assert is_drying_active({"dry_time": 720}) is True
- # Strings appear in some payload shapes.
- assert is_drying_active({"dry_time": "45"}) is True
- def test_idle_unit_is_not_active(self):
- assert is_drying_active({"dry_time": 0, "dry_status": 0}) is False
- assert is_drying_active({}) is False
- def test_cooling_phase_counts_as_active(self):
- # The reason dry_time alone is not enough: the cycle's own cooling phase
- # runs with the countdown already at 0.
- assert is_drying_active({"dry_time": 0, "dry_status": 3}) is True
- def test_checking_and_drying_phases_count_as_active(self):
- assert is_drying_active({"dry_time": 0, "dry_status": 1}) is True
- assert is_drying_active({"dry_time": 0, "dry_status": 2}) is True
- def test_ending_phases_do_not_count_as_active(self):
- # 4=Stopping, 5=Error — the cycle is over or aborting.
- assert is_drying_active({"dry_time": 0, "dry_status": 4}) is False
- assert is_drying_active({"dry_time": 0, "dry_status": 5}) is False
- def test_heat_out_of_control_is_not_active(self):
- # 6=HeatOutOfControl is the one phase where a high-temperature alarm is
- # exactly what the user needs, so it must never read as expected heat.
- assert is_drying_active({"dry_time": 0, "dry_status": 6}) is False
- def test_missing_dry_status_falls_back_to_countdown(self):
- # Firmware that never sends a parseable `info` has no dry_status at all.
- assert is_drying_active({"dry_time": 30}) is True
- assert is_drying_active({"dry_time": 0}) is False
- def test_unparseable_values_do_not_raise(self):
- assert is_drying_active({"dry_time": "junk", "dry_status": 2}) is True
- assert is_drying_active({"dry_time": None, "dry_status": None}) is False
- assert is_drying_active({"dry_time": "junk", "dry_status": "junk"}) is False
- def test_non_mapping_input_is_not_active(self):
- assert is_drying_active(None) is False
- assert is_drying_active("drying") is False
- assert is_drying_active(42) is False
- class TestTemperatureAlarmSuppressed:
- """Latch behaviour for the AMS high-temperature alarm during drying (#1802)."""
- NOW = datetime(2026, 8, 16, 12, 0, tzinfo=timezone.utc)
- GRACE = 120
- def _call(self, **overrides):
- kwargs = {
- "drying_active": False,
- "temperature": 50.0,
- "threshold": 35.0,
- "latched_at": None,
- "now": self.NOW,
- "grace_minutes": self.GRACE,
- }
- kwargs.update(overrides)
- return temperature_alarm_suppressed(**kwargs)
- def test_no_drying_no_latch_alarms_normally(self):
- # The pre-#1802 behaviour has to survive untouched for units that never dry.
- suppress, latch = self._call(temperature=40.0)
- assert suppress is False
- assert latch is None
- def test_drying_suppresses_and_sets_latch(self):
- suppress, latch = self._call(drying_active=True, temperature=65.0)
- assert suppress is True
- assert latch == self.NOW
- def test_drying_latches_even_when_below_threshold(self):
- # Early in a cycle the unit is still heating up. The latch has to be set
- # then too, or the cool-down afterwards starts unprotected.
- suppress, latch = self._call(drying_active=True, temperature=28.0)
- assert suppress is True
- assert latch == self.NOW
- def test_still_hot_after_cycle_stays_suppressed(self):
- # The reported symptom: alarms kept arriving while the unit cooled.
- suppress, latch = self._call(
- temperature=52.0,
- latched_at=self.NOW - timedelta(minutes=20),
- )
- assert suppress is True
- assert latch == self.NOW - timedelta(minutes=20)
- def test_cooled_back_to_normal_clears_latch(self):
- suppress, latch = self._call(
- temperature=34.0,
- latched_at=self.NOW - timedelta(minutes=40),
- )
- assert suppress is False
- assert latch is None
- def test_exactly_at_threshold_counts_as_cooled(self):
- # The alarm itself fires on `> threshold`, so `== threshold` is not hot.
- suppress, latch = self._call(
- temperature=35.0,
- latched_at=self.NOW - timedelta(minutes=40),
- )
- assert suppress is False
- assert latch is None
- def test_alarms_again_after_the_latch_is_cleared(self):
- # Having cooled once, a later genuine overheat is not swallowed.
- _, latch = self._call(temperature=34.0, latched_at=self.NOW - timedelta(minutes=40))
- suppress, latch = self._call(temperature=48.0, latched_at=latch)
- assert suppress is False
- assert latch is None
- def test_grace_cap_releases_a_unit_that_never_cools(self):
- # A unit stuck above the threshold would have alarmed with no drying
- # involved, so the cap restores that rather than inventing an alert.
- suppress, latch = self._call(
- temperature=45.0,
- latched_at=self.NOW - timedelta(minutes=self.GRACE + 1),
- )
- assert suppress is False
- assert latch is None
- def test_grace_cap_boundary_releases(self):
- suppress, _ = self._call(
- temperature=45.0,
- latched_at=self.NOW - timedelta(minutes=self.GRACE),
- )
- assert suppress is False
- def test_just_inside_the_grace_cap_still_suppresses(self):
- suppress, _ = self._call(
- temperature=45.0,
- latched_at=self.NOW - timedelta(minutes=self.GRACE - 1),
- )
- assert suppress is True
- def test_a_new_cycle_refreshes_the_latch(self):
- # Starting a second dry inside the grace window must restart the clock,
- # otherwise the cap could expire midway through the new cycle.
- suppress, latch = self._call(
- drying_active=True,
- temperature=60.0,
- latched_at=self.NOW - timedelta(minutes=self.GRACE - 5),
- )
- assert suppress is True
- assert latch == self.NOW
- def test_unreadable_temperature_holds_the_latch(self):
- # A dropped reading is not evidence the unit cooled, and there is no
- # alarm to fire on this pass anyway.
- suppress, latch = self._call(
- temperature=None,
- latched_at=self.NOW - timedelta(minutes=10),
- )
- assert suppress is True
- assert latch == self.NOW - timedelta(minutes=10)
- def test_the_cap_is_measured_from_the_latch(self):
- # Guards the precondition the loader's clamp exists to maintain: with a
- # non-future latch, suppression expires exactly one cap after it, so the
- # cap is a real bound rather than a floor. A future latch would push the
- # release out by the skew as well, which is why the clamp is at the read
- # — see _load_ams_drying_latch and its persistence tests.
- latched = self.NOW - timedelta(minutes=self.GRACE)
- suppress, latch = temperature_alarm_suppressed(
- drying_active=False,
- temperature=45.0,
- threshold=35.0,
- latched_at=latched,
- now=self.NOW,
- grace_minutes=self.GRACE,
- )
- assert suppress is False
- assert latch is None
- class TestTempAlarmThresholdResolution:
- """The alarm gets its own threshold, falling back to the display band (#2905).
- ``ams_temp_fair`` decides when the AMS card turns amber and used to decide
- when a notification was sent as well. 35 C is a reasonable place to change a
- colour and not a reasonable place to page someone: a room above it made the
- alarm fire once an hour for as long as the weather lasted, and the only way
- to stop it was to raise the display band and lose the colour that says the
- unit is warm.
- """
- def test_unset_falls_back_to_the_fair_threshold(self):
- """Every install that has never set one keeps behaving exactly as it does
- now — that is what makes this safe to ship without a migration."""
- assert _resolve_temp_alarm_threshold(35.0, None) == 35.0
- def test_the_literal_none_string_falls_back_too(self):
- """Settings storage stringifies None, so "not set" arrives as a string.
- Handled by the same branch as any other unparseable value rather than by
- a sentinel that has to be kept in sync."""
- assert _resolve_temp_alarm_threshold(35.0, "None") == 35.0
- def test_a_set_value_wins(self):
- assert _resolve_temp_alarm_threshold(35.0, "45") == 45.0
- assert _resolve_temp_alarm_threshold(35.0, "45.5") == 45.5
- def test_it_tracks_an_edited_fair_threshold_while_unset(self):
- """Seeded from the resolved fair value, not from a hardcoded 35."""
- assert _resolve_temp_alarm_threshold(40.0, None) == 40.0
- def test_a_value_below_the_display_band_is_honoured(self):
- """Nothing requires the alarm to sit above the amber band. Someone who
- wants to be told before the card even changes colour may say so."""
- assert _resolve_temp_alarm_threshold(35.0, "30") == 30.0
- def test_garbage_falls_back_rather_than_raising(self):
- assert _resolve_temp_alarm_threshold(35.0, "") == 35.0
- assert _resolve_temp_alarm_threshold(35.0, "warm") == 35.0
- def test_zero_and_negative_are_refused(self):
- """Zero would alarm permanently, and is far more likely to be a cleared
- field than a deliberate choice."""
- assert _resolve_temp_alarm_threshold(35.0, "0") == 35.0
- assert _resolve_temp_alarm_threshold(35.0, "-5") == 35.0
- def test_nan_and_infinity_are_refused(self):
- assert _resolve_temp_alarm_threshold(35.0, "nan") == 35.0
- assert _resolve_temp_alarm_threshold(35.0, "inf") == 35.0
- class TestLatchReleasesAtTheAlarmThreshold:
- """The latch's release check takes the alarm threshold, not the display band.
- ``temperature_alarm_suppressed`` releases once the unit reads at or below
- ``threshold``. Handing it the display band strands the latch on any unit that
- settles back above it — which is not hypothetical: the AMS this was reported
- from rests at 37.7 C in a warm room and never returns under a 35 C band, so
- the latch could only ever expire on the grace cap.
- """
- def test_a_unit_resting_above_the_display_band_releases_on_the_alarm_threshold(self):
- """37.7 C after a cycle, alarm threshold 45: released promptly."""
- latched = datetime.now(timezone.utc) - timedelta(minutes=5)
- suppress, latch = temperature_alarm_suppressed(
- drying_active=False,
- temperature=37.7,
- threshold=45.0,
- latched_at=latched,
- now=datetime.now(timezone.utc),
- grace_minutes=120,
- )
- assert suppress is False
- assert latch is None
- def test_the_same_reading_stays_latched_against_the_display_band(self):
- """The behaviour before this change, kept as the contrast: 37.7 C never
- drops under 35, so the latch survives and can only expire on the cap."""
- latched = datetime.now(timezone.utc) - timedelta(minutes=5)
- suppress, latch = temperature_alarm_suppressed(
- drying_active=False,
- temperature=37.7,
- threshold=35.0,
- latched_at=latched,
- now=datetime.now(timezone.utc),
- grace_minutes=120,
- )
- assert suppress is True
- assert latch == latched
- def test_a_genuinely_hot_unit_still_alarms_after_the_cap(self):
- """Releasing on the cap is not a new alert — a unit that stays that hot
- would have been alarming with no drying involved."""
- latched = datetime.now(timezone.utc) - timedelta(minutes=121)
- suppress, latch = temperature_alarm_suppressed(
- drying_active=False,
- temperature=70.0,
- threshold=45.0,
- latched_at=latched,
- now=datetime.now(timezone.utc),
- grace_minutes=120,
- )
- assert suppress is False
- assert latch is None
|