Browse Source

Give the AMS temperature alarm its own threshold (issue #2905) (#2943)

Kouki Ojima 1 tuần trước cách đây
mục cha
commit
c3677865b6

+ 15 - 0
backend/app/api/routes/settings.py

@@ -212,6 +212,16 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "low_stock_threshold",
         ]:
             settings_dict[setting.key] = float(setting.value)
+        elif setting.key in [
+            # Nullable floats. Settings storage stringifies None to the literal
+            # "None", so these cannot go in the list above -- float("None")
+            # raises and would take the whole settings response with it (#2905).
+            "ams_temp_alarm",
+        ]:
+            try:
+                settings_dict[setting.key] = float(setting.value)
+            except (TypeError, ValueError):
+                settings_dict[setting.key] = None
         elif setting.key in [
             "ams_humidity_good",
             "ams_humidity_fair",
@@ -447,6 +457,11 @@ _UI_PREFERENCE_FIELDS: tuple[str, ...] = (
     "ams_humidity_fair",
     "ams_temp_good",
     "ams_temp_fair",
+    # ams_temp_alarm is deliberately NOT here. This endpoint is unauthenticated
+    # and exists so the UI can colour readings without SETTINGS_READ; the good /
+    # fair bands are what the printer card colours by. The alarm threshold
+    # changes no rendering anywhere -- only SettingsPage reads it, and that is
+    # behind the settings permissions already (#2905).
     "bed_cooled_threshold",
     # Temperature / fan-speed presets for the printer-card popovers. Numbers
     # only; no PII / credentials.

+ 66 - 7
backend/app/main.py

@@ -1,6 +1,7 @@
 import asyncio
 import json
 import logging
+import math
 import os
 import posixpath
 import secrets
@@ -7443,6 +7444,36 @@ _ams_cleanup_counter = 0  # Track recordings to trigger periodic cleanup
 _ams_alarm_cooldown: dict[str, datetime] = {}
 AMS_ALARM_COOLDOWN_MINUTES = 60  # Don't send same alarm more than once per hour
 
+
+def _resolve_temp_alarm_threshold(fair_threshold: float, raw_alarm_value: str | None) -> float:
+    """Temperature at which the AMS alarm fires, falling back to the display band.
+
+    ``ams_temp_fair`` decides when the AMS card turns amber. It used to decide
+    when a notification was sent as well, which is why a room above it made the
+    alarm fire once an hour for as long as the weather lasted -- and the only way
+    to stop that was to raise the display band and lose the colour that says the
+    unit is warm (#2905).
+
+    Unset resolves to the fair threshold, so an install that never sets one is
+    unchanged. Settings storage stringifies ``None`` to the literal ``"None"``,
+    so that arrives here as a string and is handled by the same branch as any
+    other unparseable value -- there is no separate sentinel to keep in sync.
+
+    A non-positive value is refused rather than honoured: zero would alarm
+    permanently, and it is far more likely to be a cleared field than a
+    deliberate choice.
+    """
+    if raw_alarm_value is None:
+        return fair_threshold
+    try:
+        value = float(raw_alarm_value)
+    except (TypeError, ValueError):
+        return fair_threshold
+    if not math.isfinite(value) or value <= 0:
+        return fair_threshold
+    return value
+
+
 # Per-AMS "drying was live at" latch that suppresses the high-temperature alarm
 # through a cycle and the cool-down after it (#1802). Stored in the settings
 # table rather than alongside _ams_alarm_cooldown above, because a restart
@@ -7572,7 +7603,7 @@ async def record_ams_history():
 
                 # Get alarm thresholds from settings
                 humidity_threshold = 60.0  # Default: fair threshold
-                temp_threshold = 35.0  # Default: fair threshold
+                temp_fair_threshold = 35.0  # Display band default (ams_temp_fair)
                 result = await db.execute(select(Settings).where(Settings.key == "ams_humidity_fair"))
                 setting = result.scalar_one_or_none()
                 if setting:
@@ -7584,10 +7615,28 @@ async def record_ams_history():
                 setting = result.scalar_one_or_none()
                 if setting:
                     try:
-                        temp_threshold = float(setting.value)
+                        temp_fair_threshold = float(setting.value)
                     except (ValueError, TypeError):
                         pass  # Keep default threshold if stored value is invalid
 
+                # The alarm gets its own threshold, seeded from the resolved fair
+                # value so an install that has never set one behaves exactly as
+                # it did before (#2905). ams_temp_fair decides when the card turns
+                # amber; 35 C is a reasonable place to change a colour and not a
+                # reasonable place to page someone. A room above it makes the
+                # alarm fire once an hour for as long as the weather lasts, and
+                # the only way to stop it was to raise the display band and lose
+                # the colour that says the unit is warm.
+                #
+                # An unset value is stored as the literal "None", which the except
+                # below swallows the same way it swallows garbage -- so the
+                # fallback costs nothing and needs no sentinel of its own.
+                result = await db.execute(select(Settings).where(Settings.key == "ams_temp_alarm"))
+                setting = result.scalar_one_or_none()
+                temp_alarm_threshold = _resolve_temp_alarm_threshold(
+                    temp_fair_threshold, setting.value if setting else None
+                )
+
                 # Per-filament humidity threshold overrides (#1605) — resolved
                 # per-AMS below from the loaded tray types. Reuses the same
                 # resolver as the auto-drying scheduler so behavior stays in
@@ -7742,10 +7791,16 @@ async def record_ams_history():
                         # returns to normal. Humidity is deliberately left alone:
                         # it falls during drying, which is the whole point.
                         latch_key = f"{printer.id}:{ams_id}"
+                        # The latch releases at `threshold`, so it takes the alarm
+                        # number too. Handing it the display band would strand the
+                        # latch on any unit that settles back above it -- a room
+                        # where the AMS rests at 37.7 C never returns under a 35 C
+                        # band, so the latch could only expire on the grace cap
+                        # rather than releasing when the unit had actually cooled.
                         suppress_temp_alarm, new_latch = temperature_alarm_suppressed(
                             drying_active=is_drying_active(ams_data),
                             temperature=temperature,
-                            threshold=temp_threshold,
+                            threshold=temp_alarm_threshold,
                             latched_at=drying_latch.get(latch_key),
                             now=datetime.now(timezone.utc),
                             grace_minutes=AMS_DRYING_GRACE_MINUTES,
@@ -7756,7 +7811,7 @@ async def record_ams_history():
                             drying_latch[latch_key] = new_latch
 
                         # Check temperature alarm (only if above threshold)
-                        if temperature is not None and temperature > temp_threshold and not suppress_temp_alarm:
+                        if temperature is not None and temperature > temp_alarm_threshold and not suppress_temp_alarm:
                             cooldown_key = f"{printer.id}:{ams_id}:temperature"
                             last_alarm = _ams_alarm_cooldown.get(cooldown_key)
                             now = datetime.now(timezone.utc)
@@ -7766,17 +7821,21 @@ async def record_ams_history():
                             ):
                                 _ams_alarm_cooldown[cooldown_key] = now
                                 logger.info(
-                                    f"Sending temperature alarm for {printer.name} {ams_label}: {temperature}°C > {temp_threshold}°C"
+                                    f"Sending temperature alarm for {printer.name} {ams_label}: "
+                                    f"{temperature}°C > {temp_alarm_threshold}°C"
                                 )
                                 try:
                                     # Call different notification method based on AMS type
                                     if is_ams_ht:
+                                        # The reported threshold has to be the one
+                                        # that fired, or the message says "> 35 °C"
+                                        # while firing at 45.
                                         await notification_service.on_ams_ht_temperature_high(
-                                            printer.id, printer.name, ams_label, temperature, temp_threshold, db
+                                            printer.id, printer.name, ams_label, temperature, temp_alarm_threshold, db
                                         )
                                     else:
                                         await notification_service.on_ams_temperature_high(
-                                            printer.id, printer.name, ams_label, temperature, temp_threshold, db
+                                            printer.id, printer.name, ams_label, temperature, temp_alarm_threshold, db
                                         )
                                 except Exception as e:
                                     logger.warning("Failed to send temperature alarm: %s", e)

+ 13 - 0
backend/app/schemas/settings.py

@@ -115,6 +115,18 @@ class AppSettings(BaseModel):
     ams_temp_fair: float = Field(
         default=35.0, description="Temperature threshold for fair (orange): <= this value, > is red"
     )
+    # Separate from ams_temp_fair on purpose (#2905). The fair threshold decides
+    # when the AMS card turns amber; this decides when a notification is sent.
+    # 35 C is a sensible place to change a colour and not a sensible place to
+    # page someone -- a room above 35 C makes the alarm fire once an hour for as
+    # long as the weather lasts, and the only way to silence it was to raise the
+    # display band and lose the colour that says the unit is warm. None means
+    # "not set", which resolves to ams_temp_fair so every existing install keeps
+    # behaving exactly as it does now.
+    ams_temp_alarm: float | None = Field(
+        default=None,
+        description="Temperature threshold (°C) for sending an alarm. Unset falls back to ams_temp_fair.",
+    )
     ams_history_retention_days: int = Field(default=30, description="Number of days to keep AMS sensor history data")
     printer_sensor_history_retention_days: int = Field(
         default=30, description="Number of days to keep printer heater history data (nozzle / bed / chamber)"
@@ -664,6 +676,7 @@ class AppSettingsUpdate(BaseModel):
     ams_humidity_fair: int | None = None
     ams_temp_good: float | None = None
     ams_temp_fair: float | None = None
+    ams_temp_alarm: float | None = None
     ams_history_retention_days: int | None = None
     printer_sensor_history_retention_days: int | None = None
     queue_drying_enabled: bool | None = None

+ 86 - 0
backend/tests/integration/test_settings_api.py

@@ -41,6 +41,92 @@ class TestSettingsAPI:
         assert isinstance(result["auto_archive"], bool)
         assert isinstance(result["currency"], str)
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unset_temp_alarm_reads_back_as_null(self, async_client: AsyncClient, db_session):
+        """#2905: ams_temp_alarm is nullable, and settings storage stringifies
+        None to the literal "None".
+
+        Putting it in the plain float-cast list would make float("None") raise
+        inside the response builder and take the whole settings response with it
+        — every unrelated setting on the page included.
+        """
+        from backend.app.models.settings import Settings
+
+        db_session.add(Settings(key="ams_temp_alarm", value="None"))
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/settings/")
+
+        assert response.status_code == 200
+        assert response.json()["ams_temp_alarm"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_set_temp_alarm_reads_back_as_a_float(self, async_client: AsyncClient, db_session):
+        from backend.app.models.settings import Settings
+
+        db_session.add(Settings(key="ams_temp_alarm", value="45"))
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/settings/")
+
+        assert response.status_code == 200
+        assert response.json()["ams_temp_alarm"] == 45.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_malformed_temp_alarm_does_not_break_the_response(self, async_client: AsyncClient, db_session):
+        """A hand-edited or half-written value must degrade to "unset" rather
+        than making the settings page unreachable."""
+        from backend.app.models.settings import Settings
+
+        db_session.add(Settings(key="ams_temp_alarm", value="warm"))
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/settings/")
+
+        assert response.status_code == 200
+        assert response.json()["ams_temp_alarm"] is None
+        assert "currency" in response.json(), "the rest of the page still renders"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_temp_alarm_survives_a_set_then_clear_round_trip(self, async_client: AsyncClient):
+        """The path the settings page actually takes, end to end (#2905).
+
+        The tests above seed rows directly, which pins the read but not the
+        convention the whole design rests on: clearing the field sends an
+        explicit ``null``, ``update_settings`` stores that as the literal
+        string ``"None"``, and the response builder has to turn it back into
+        ``None``. A regression anywhere along that chain would leave a cleared
+        threshold reading back as the old number, and no test above would fail.
+        """
+        from sqlalchemy import select
+
+        # Imported here, not at module scope: the async_client fixture patches
+        # core.database.async_session onto the test engine, so a name bound at
+        # import time would point at the real app database and find nothing.
+        from backend.app.core.database import async_session
+        from backend.app.models.settings import Settings
+
+        response = await async_client.put("/api/v1/settings/", json={"ams_temp_alarm": 45})
+        assert response.status_code == 200
+        assert response.json()["ams_temp_alarm"] == 45.0
+        assert (await async_client.get("/api/v1/settings/")).json()["ams_temp_alarm"] == 45.0
+
+        response = await async_client.put("/api/v1/settings/", json={"ams_temp_alarm": None})
+        assert response.status_code == 200
+        assert response.json()["ams_temp_alarm"] is None
+        assert (await async_client.get("/api/v1/settings/")).json()["ams_temp_alarm"] is None
+
+        # Pin the stored form too — the fallback in _resolve_temp_alarm_threshold
+        # is written against this exact string, so a storage change that silently
+        # switched to "" or NULL would break the alarm rather than this test.
+        async with async_session() as db:
+            row = (await db.execute(select(Settings).where(Settings.key == "ams_temp_alarm"))).scalar_one()
+        assert row.value == "None"
+
     # ========================================================================
     # Update settings
     # ========================================================================

+ 106 - 1
backend/tests/unit/test_ams_alarm_gating.py

@@ -17,7 +17,7 @@ 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
+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
 
 
@@ -270,3 +270,108 @@ class TestTemperatureAlarmSuppressed:
         )
         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

+ 180 - 0
backend/tests/unit/test_ams_temp_alarm_dispatch_2905.py

@@ -0,0 +1,180 @@
+"""The temperature alarm reads ams_temp_alarm, end to end (issue #2905).
+
+``test_ams_alarm_gating`` covers the pieces -- the resolution, the latch's
+release check -- but not the thing that consumes them. ``record_ams_history``
+is a no-arg infinite task, which is why the gates already there are tested
+through their extracted helpers instead, and why #2943 said the wiring itself
+was verified by reading rather than by a test.
+
+It can be driven. The loop exits cleanly on ``asyncio.CancelledError``, so a
+fake ``asyncio.sleep`` that recognises the loop's own intervals runs exactly
+one pass and stops: skip the 10 s startup wait, raise at the 300 s
+end-of-pass wait. The 60 s wait belongs to the loop's ``except Exception``
+handler, so intercepting that too turns a swallowed error into a failure with
+a message rather than a test that quietly asserts nothing.
+
+What that buys is the only coverage of the three call sites together: which
+number decides that the alarm fires, and which number the notification quotes.
+Those were separate values before this change and a regression that reverted
+either one would leave every test in the other file passing.
+"""
+
+import asyncio
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+import backend.app.main as main
+from backend.app.models.printer import Printer
+from backend.app.models.settings import Settings
+
+
+def _ams_state_at(temperature: float):
+    """A connected printer with one loaded AMS reading *temperature*.
+
+    ``tray_exist_bits`` matters: the #1619 empty-unit skip drops the alarm
+    before any threshold is consulted, so a unit with no filament would make
+    every assertion below vacuously pass.
+    """
+    state = MagicMock()
+    state.connected = True
+    state.raw_data = {
+        "ams": [
+            {
+                "id": 0,
+                "temp": str(temperature),
+                "humidity": "5",
+                "humidity_raw": "39",  # inside the good band — no humidity alarm
+                "tray_exist_bits": "1",
+                "tray": [{"tray_type": "PLA"}],
+            }
+        ]
+    }
+    return state
+
+
+async def _run_one_pass(test_engine, temperature: float):
+    """Run record_ams_history exactly once and return the mocked service."""
+    real_sleep = asyncio.sleep
+
+    async def fake_sleep(seconds):
+        if seconds == 10:  # startup wait before the first pass
+            return
+        if seconds == main.AMS_HISTORY_INTERVAL:  # pass finished cleanly
+            raise asyncio.CancelledError
+        if seconds == 60:  # the loop's own except-Exception backoff
+            raise AssertionError("record_ams_history raised; check the warning log")
+        await real_sleep(seconds)
+
+    service = MagicMock()
+    service.on_ams_temperature_high = AsyncMock()
+    service.on_ams_ht_temperature_high = AsyncMock()
+    service.on_ams_humidity_high = AsyncMock()
+
+    maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    cooldown_before = dict(main._ams_alarm_cooldown)
+    counter_before = main._ams_cleanup_counter
+    main._ams_alarm_cooldown.clear()
+    try:
+        with (
+            patch.object(main, "async_session", maker),
+            patch.object(main, "notification_service", service),
+            patch.object(main.printer_manager, "get_status", return_value=_ams_state_at(temperature)),
+            patch.object(main.asyncio, "sleep", fake_sleep),
+        ):
+            await main.record_ams_history()
+    finally:
+        main._ams_alarm_cooldown.clear()
+        main._ams_alarm_cooldown.update(cooldown_before)
+        main._ams_cleanup_counter = counter_before
+    return service
+
+
+async def _printer(db) -> Printer:
+    printer = Printer(name="X2D", serial_number="S-2905", ip_address="1.1.1.1", access_code="c", model="X2D")
+    db.add(printer)
+    await db.commit()
+    return printer
+
+
+async def _set_alarm(db, value: str) -> None:
+    db.add(Settings(key="ams_temp_alarm", value=value))
+    await db.commit()
+
+
+@pytest.mark.asyncio
+async def test_an_install_that_never_set_one_alarms_at_the_display_band(db_session, test_engine):
+    """The upgrade path, which is what makes this shippable without a migration.
+
+    No ams_temp_alarm row: the alarm has to fire where it always did, and quote
+    the number it always quoted.
+    """
+    await _printer(db_session)
+
+    service = await _run_one_pass(test_engine, temperature=50.0)
+
+    service.on_ams_temperature_high.assert_awaited_once()
+    assert service.on_ams_temperature_high.await_args.args[4] == 35.0
+
+
+@pytest.mark.asyncio
+async def test_a_set_threshold_is_both_what_fires_and_what_the_message_quotes(db_session, test_engine):
+    """The two call sites, asserted together.
+
+    A change that fixed the comparison and left the reported value behind would
+    send "50 °C > 35 °C" while the user had asked for 45 — which reads as the
+    old bug rather than as a working alarm.
+    """
+    await _printer(db_session)
+    await _set_alarm(db_session, "45")
+
+    service = await _run_one_pass(test_engine, temperature=50.0)
+
+    service.on_ams_temperature_high.assert_awaited_once()
+    temperature, threshold = service.on_ams_temperature_high.await_args.args[3:5]
+    assert (temperature, threshold) == (50.0, 45.0)
+
+
+@pytest.mark.asyncio
+async def test_ambient_room_heat_below_the_alarm_threshold_sends_nothing(db_session, test_engine):
+    """#2905 itself: 37.7 C in a room without air conditioning, alarm at 45.
+
+    That reading is the one from the report, an hour apart, twice. Nothing is
+    heating and nothing is wrong, so nothing should be sent.
+    """
+    await _printer(db_session)
+    await _set_alarm(db_session, "45")
+
+    service = await _run_one_pass(test_engine, temperature=37.7)
+
+    assert service.on_ams_temperature_high.await_count == 0
+
+
+@pytest.mark.asyncio
+async def test_clearing_the_field_restores_the_old_behaviour(db_session, test_engine):
+    """What the settings page writes when the input is emptied is the literal
+    string "None", not a missing row. It has to land back on the fair value."""
+    await _printer(db_session)
+    await _set_alarm(db_session, "None")
+
+    service = await _run_one_pass(test_engine, temperature=50.0)
+
+    service.on_ams_temperature_high.assert_awaited_once()
+    assert service.on_ams_temperature_high.await_args.args[4] == 35.0
+
+
+@pytest.mark.asyncio
+async def test_an_unusable_value_falls_back_instead_of_silencing_the_alarm(db_session, test_engine):
+    """A stored "nan" parses as a float, and nothing is ever greater than NaN.
+
+    Without the isfinite guard the alarm would go permanently quiet — the
+    failure mode that looks exactly like a working configuration.
+    """
+    await _printer(db_session)
+    await _set_alarm(db_session, "nan")
+
+    service = await _run_one_pass(test_engine, temperature=50.0)
+
+    service.on_ams_temperature_high.assert_awaited_once()
+    assert service.on_ams_temperature_high.await_args.args[4] == 35.0

+ 1 - 0
frontend/src/__tests__/mocks/handlers.ts

@@ -262,6 +262,7 @@ export const handlers = [
       ams_humidity_fair: 60,
       ams_temp_good: 30,
       ams_temp_fair: 35,
+      ams_temp_alarm: null,
     });
   }),
 

+ 89 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -27,6 +27,7 @@ const mockSettings = {
   ams_humidity_fair: 60,
   ams_temp_good: 30,
   ams_temp_fair: 35,
+  ams_temp_alarm: null,
   time_format: 'system',
   date_format: 'system',
   mqtt_enabled: false,
@@ -771,6 +772,94 @@ describe('SettingsPage', () => {
 
       expect(screen.queryByText(/Auto-drying cannot reach this value/)).not.toBeInTheDocument();
     });
+
+    // #2905: the alarm threshold is a separate value from the Fair display
+    // band, and unset means "use Fair" rather than "never alarm". Everything
+    // below turns on telling those two apart.
+    const openFilamentTabWith = async (overrides: Record<string, unknown>) => {
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({ ...mockSettings, ...overrides }))
+      );
+      const user = userEvent.setup();
+      render(<SettingsPage />);
+      await waitFor(() => {
+        expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
+      });
+      await user.click(screen.getAllByText('Filament')[0]);
+      await waitFor(() => {
+        expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument();
+      });
+    };
+
+    const alarmInput = () =>
+      within(screen.getByText('Alarm above').parentElement!).getByRole('spinbutton');
+
+    it('shows the fair threshold as the placeholder while the alarm threshold is unset', async () => {
+      // The fallback has to be visible in the field itself. Blank with no hint
+      // reads as "no alarm", which is the opposite of what unset does.
+      await openFilamentTabWith({ ams_temp_fair: 38, ams_temp_alarm: null });
+
+      const input = alarmInput();
+      expect(input).toHaveValue(null);
+      expect(input).toHaveAttribute('placeholder', '38');
+    });
+
+    it('sends the typed alarm threshold on save', async () => {
+      let saved: Record<string, unknown> | null = null;
+      server.use(
+        http.put('/api/v1/settings/', async ({ request }) => {
+          saved = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ ...mockSettings, ...saved });
+        })
+      );
+      await openFilamentTabWith({ ams_temp_alarm: null });
+      // The page suppresses auto-save for 100ms after the settings load.
+      await new Promise((resolve) => setTimeout(resolve, 200));
+
+      await userEvent.type(alarmInput(), '45');
+
+      // Assert on the value rather than merely on a save having happened: two
+      // keystrokes can straddle the 500ms debounce on a slow runner, and the
+      // first save would then carry 4. Waiting for 45 rides that out.
+      await waitFor(() => {
+        expect(saved?.ams_temp_alarm).toBe(45);
+      }, { timeout: 3000 });
+    });
+
+    it('sends null when the alarm threshold is cleared', async () => {
+      // The one path the backend tests cannot reach on their own: clearing has
+      // to send an explicit null, not omit the key, or the old threshold stays.
+      let saved: Record<string, unknown> | null = null;
+      server.use(
+        http.put('/api/v1/settings/', async ({ request }) => {
+          saved = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ ...mockSettings, ...saved });
+        })
+      );
+      await openFilamentTabWith({ ams_temp_alarm: 45 });
+      await new Promise((resolve) => setTimeout(resolve, 200));
+
+      await userEvent.clear(alarmInput());
+
+      await waitFor(() => {
+        expect(saved).not.toBeNull();
+        expect(saved!.ams_temp_alarm).toBeNull();
+      }, { timeout: 3000 });
+    });
+
+    it('warns that a non-positive alarm threshold is ignored', async () => {
+      // The backend refuses <= 0 and falls back to Fair. Saying so beats a min=
+      // attribute the browser only enforces on submit.
+      await openFilamentTabWith({ ams_temp_alarm: 0 });
+
+      expect(await screen.findByText(/A threshold of 0 or less is ignored/)).toBeInTheDocument();
+    });
+
+    it('stays quiet for an alarm threshold the backend will honour', async () => {
+      await openFilamentTabWith({ ams_temp_alarm: 45 });
+
+      expect(screen.queryByText(/A threshold of 0 or less is ignored/)).not.toBeInTheDocument();
+    });
   });
 
   describe('Workflow tab', () => {

+ 3 - 0
frontend/src/api/client.ts

@@ -1281,6 +1281,9 @@ export interface AppSettings {
   ams_humidity_fair: number;  // <= this is orange, > is red
   ams_temp_good: number;      // <= this is green/blue
   ams_temp_fair: number;      // <= this is orange, > is red
+  // Separate from the display band (#2905). null = unset, which the backend
+  // resolves to ams_temp_fair so existing installs are unaffected.
+  ams_temp_alarm: number | null;
   ams_history_retention_days: number;  // days to keep AMS sensor history
   // Queue auto-drying settings
   queue_drying_enabled: boolean;  // Auto-dry AMS between queued prints

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -2282,6 +2282,9 @@ export default {
     temperature: 'Temperatur',
     goodBlue: 'Gut (blau)',
     aboveFairHot: 'Über dem mittleren Schwellenwert wird rot angezeigt (heiß)',
+    tempAlarmThreshold: 'Alarm über',
+    tempAlarmSeparateFromBand: 'Nur der Alarm-Schwellenwert löst Benachrichtigungen aus — Gut und Mittel färben nur die Anzeige. Leer lassen, um wie bisher beim mittleren Schwellenwert zu alarmieren.',
+    tempAlarmMustBePositive: 'Ein Schwellenwert von 0 oder weniger wird ignoriert — der Alarm nutzt dann den mittleren Schwellenwert.',
     historyRetention: 'Verlaufsaufbewahrung',
     keepSensorHistory: 'Sensorverlauf behalten für',
     historyRetentionDescription: 'Ältere Feuchtigkeits- und Temperaturdaten werden automatisch gelöscht',

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -2302,6 +2302,9 @@ export default {
     temperature: 'Temperature',
     goodBlue: 'Good (blue)',
     aboveFairHot: 'Above fair threshold shows as red (hot)',
+    tempAlarmThreshold: 'Alarm above',
+    tempAlarmSeparateFromBand: 'Only the alarm threshold sends notifications — Good and Fair colour the display. Leave it empty to alarm at the fair threshold, as before.',
+    tempAlarmMustBePositive: 'A threshold of 0 or less is ignored — the alarm falls back to the fair threshold.',
     historyRetention: 'History Retention',
     keepSensorHistory: 'Keep sensor history for',
     historyRetentionDescription: 'Older humidity and temperature data will be automatically deleted',

+ 3 - 0
frontend/src/i18n/locales/es.ts

@@ -2285,6 +2285,9 @@ export default {
     temperature: 'Temperatura',
     goodBlue: 'Buena (azul)',
     aboveFairHot: 'Por encima del umbral aceptable se muestra en rojo (caliente)',
+    tempAlarmThreshold: 'Alarma por encima de',
+    tempAlarmSeparateFromBand: 'Solo el umbral de alarma envía notificaciones: Bueno y Aceptable únicamente colorean la pantalla. Déjalo vacío para alarmar en el umbral aceptable, como antes.',
+    tempAlarmMustBePositive: 'Un umbral de 0 o inferior se ignora: la alarma usa el umbral aceptable.',
     historyRetention: 'Retención del historial',
     keepSensorHistory: 'Conservar el historial del sensor durante',
     historyRetentionDescription: 'Los datos de humedad y temperatura más antiguos se eliminarán automáticamente',

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -2238,6 +2238,9 @@ export default {
     temperature: 'Température',
     goodBlue: 'Bon (bleu)',
     aboveFairHot: 'Au-dessus = rouge (chaud)',
+    tempAlarmThreshold: 'Alerte au-dessus de',
+    tempAlarmSeparateFromBand: 'Seul le seuil d\'alerte envoie des notifications — Bon et Correct ne font que colorer l\'affichage. Laissez vide pour alerter au seuil correct, comme avant.',
+    tempAlarmMustBePositive: 'Un seuil de 0 ou moins est ignoré — l\'alerte utilise alors le seuil correct.',
     historyRetention: 'Rétention d\'historique',
     keepSensorHistory: 'Garder l\'historique pendant',
     historyRetentionDescription: 'Les anciennes données seront supprimées.',

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -2238,6 +2238,9 @@ export default {
     temperature: 'Temperatura',
     goodBlue: 'Buono (blu)',
     aboveFairHot: 'Sopra soglia discreta mostra rosso (caldo)',
+    tempAlarmThreshold: 'Allarme sopra',
+    tempAlarmSeparateFromBand: 'Solo la soglia di allarme invia notifiche: Buono e Discreto colorano solo la visualizzazione. Lascia vuoto per allarmare alla soglia discreta, come prima.',
+    tempAlarmMustBePositive: 'Una soglia pari o inferiore a 0 viene ignorata: l\'allarme usa la soglia discreta.',
     historyRetention: 'Conservazione cronologia',
     keepSensorHistory: 'Mantieni cronologia sensori per',
     historyRetentionDescription: 'I dati più vecchi saranno eliminati automaticamente',

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -2281,6 +2281,9 @@ export default {
     temperature: '温度',
     goodBlue: '良好(青)≤',
     aboveFairHot: '普通のしきい値以上は赤(高温)で表示',
+    tempAlarmThreshold: 'アラームのしきい値',
+    tempAlarmSeparateFromBand: '通知を出すのはアラームのしきい値だけです。良好・普通は表示の色分けのみに使われます。空欄にすると、これまでどおり普通のしきい値で発報します。',
+    tempAlarmMustBePositive: '0以下のしきい値は無視され、普通のしきい値で発報します。',
     historyRetention: '履歴の保持',
     keepSensorHistory: 'センサー履歴の保持期間',
     historyRetentionDescription: '古い湿度と温度データは自動的に削除されます',

+ 3 - 0
frontend/src/i18n/locales/ko.ts

@@ -2162,6 +2162,9 @@ export default {
     temperature: '온도',
     goodBlue: '좋음 (파란색)',
     aboveFairHot: '보통 임계값 초과 시 빨간색 (뜨거움)으로 표시',
+    tempAlarmThreshold: '알림 기준값',
+    tempAlarmSeparateFromBand: '알림을 보내는 것은 알림 기준값뿐입니다. 좋음과 보통은 표시 색상에만 사용됩니다. 비워 두면 기존과 같이 보통 기준값에서 알립니다.',
+    tempAlarmMustBePositive: '0 이하의 기준값은 무시되며, 보통 기준값으로 알립니다.',
     historyRetention: '기록 보존',
     keepSensorHistory: '센서 기록 보존 기간',
     historyRetentionDescription: '오래된 습도 및 온도 데이터가 자동으로 삭제됩니다',

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -2238,6 +2238,9 @@ export default {
     temperature: 'Temperatura',
     goodBlue: 'Bom (azul)',
     aboveFairHot: 'Acima do limiar razoável mostra como vermelho (quente)',
+    tempAlarmThreshold: 'Alarme acima de',
+    tempAlarmSeparateFromBand: 'Apenas o limiar de alarme envia notificações — Bom e Razoável apenas colorem a exibição. Deixe vazio para alarmar no limiar razoável, como antes.',
+    tempAlarmMustBePositive: 'Um limiar de 0 ou menos é ignorado — o alarme usa o limiar razoável.',
     historyRetention: 'Retenção de Histórico',
     keepSensorHistory: 'Manter histórico do sensor por',
     historyRetentionDescription: 'Dados antigos de umidade e temperatura serão automaticamente excluídos',

+ 3 - 0
frontend/src/i18n/locales/ru.ts

@@ -2162,6 +2162,9 @@ export default {
     temperature: "Температура",
     goodBlue: "Норма (синий)",
     aboveFairHot: "Выше допустимого порога отображается красным как перегрев",
+    tempAlarmThreshold: "Тревога выше",
+    tempAlarmSeparateFromBand: "Уведомления отправляет только порог тревоги — «хорошо» и «допустимо» лишь окрашивают индикатор. Оставьте поле пустым, чтобы срабатывать по допустимому порогу, как раньше.",
+    tempAlarmMustBePositive: "Порог 0 или меньше игнорируется — тревога срабатывает по допустимому порогу.",
     historyRetention: "Хранение истории",
     keepSensorHistory: "Хранить историю датчиков",
     historyRetentionDescription: "Более старые данные влажности и температуры будут удаляться автоматически",

+ 3 - 0
frontend/src/i18n/locales/tr.ts

@@ -2286,6 +2286,9 @@ export default {
     temperature: 'Sıcaklık',
     goodBlue: 'İyi (mavi)',
     aboveFairHot: 'Orta eşiğin üstü kırmızı olarak gösterilir (sıcak)',
+    tempAlarmThreshold: 'Alarm eşiği',
+    tempAlarmSeparateFromBand: 'Bildirimleri yalnızca alarm eşiği gönderir — İyi ve Orta yalnızca göstergeyi renklendirir. Eskisi gibi orta eşikte alarm vermek için boş bırakın.',
+    tempAlarmMustBePositive: 'Sıfır veya daha küçük bir eşik yok sayılır — alarm orta eşiği kullanır.',
     historyRetention: 'Geçmiş Saklama',
     keepSensorHistory: 'Sensör geçmişini sakla',
     historyRetentionDescription: 'Daha eski nem ve sıcaklık verileri otomatik olarak silinecek',

+ 3 - 0
frontend/src/i18n/locales/uk.ts

@@ -2301,6 +2301,9 @@ export default {
     temperature: "Температура",
     goodBlue: "Добре (синій)",
     aboveFairHot: "Значення вище задовільного порога відображається червоним (гаряче)",
+    tempAlarmThreshold: "Тривога вище",
+    tempAlarmSeparateFromBand: "Сповіщення надсилає лише поріг тривоги — «добре» та «задовільно» тільки забарвлюють індикатор. Залиште порожнім, щоб спрацьовувати на задовільному порозі, як раніше.",
+    tempAlarmMustBePositive: "Поріг 0 або менший ігнорується — тривога спрацьовує на задовільному порозі.",
     historyRetention: "Збереження історії",
     keepSensorHistory: "Зберігати історію датчиків для",
     historyRetentionDescription: "Старіші дані про вологість і температуру буде автоматично видалено",

+ 3 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -2283,6 +2283,9 @@ export default {
     temperature: '温度',
     goodBlue: '良好(蓝色)',
     aboveFairHot: '超过一般阈值显示为红色(热)',
+    tempAlarmThreshold: '告警阈值',
+    tempAlarmSeparateFromBand: '只有告警阈值会发送通知,良好和一般仅用于显示配色。留空则与此前一致,在一般阈值触发告警。',
+    tempAlarmMustBePositive: '小于或等于 0 的阈值将被忽略,告警改用一般阈值。',
     historyRetention: '历史保留',
     keepSensorHistory: '保留传感器历史',
     historyRetentionDescription: '较旧的湿度和温度数据将被自动删除',

+ 3 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -2283,6 +2283,9 @@ export default {
     temperature: '溫度',
     goodBlue: '良好(藍色)',
     aboveFairHot: '超過一般閾值顯示為紅色(熱)',
+    tempAlarmThreshold: '警報閾值',
+    tempAlarmSeparateFromBand: '只有警報閾值會發送通知,良好與一般僅用於顯示配色。留空則與先前一致,在一般閾值觸發警報。',
+    tempAlarmMustBePositive: '小於或等於 0 的閾值將被忽略,警報改用一般閾值。',
     historyRetention: '歷史保留',
     keepSensorHistory: '保留感測器歷史',
     historyRetentionDescription: '較舊的濕度和溫度資料將被自動刪除',

+ 43 - 0
frontend/src/pages/SettingsPage.tsx

@@ -1147,6 +1147,7 @@ export function SettingsPage() {
       baseline.ams_humidity_fair !== localSettings.ams_humidity_fair ||
       baseline.ams_temp_good !== localSettings.ams_temp_good ||
       baseline.ams_temp_fair !== localSettings.ams_temp_fair ||
+      (baseline.ams_temp_alarm ?? null) !== (localSettings.ams_temp_alarm ?? null) ||
       baseline.ams_history_retention_days !== localSettings.ams_history_retention_days ||
       baseline.disable_filament_warnings !== localSettings.disable_filament_warnings ||
       baseline.prefer_lowest_filament !== localSettings.prefer_lowest_filament ||
@@ -1258,6 +1259,7 @@ export function SettingsPage() {
         ams_humidity_fair: localSettings.ams_humidity_fair,
         ams_temp_good: localSettings.ams_temp_good,
         ams_temp_fair: localSettings.ams_temp_fair,
+        ams_temp_alarm: localSettings.ams_temp_alarm ?? null,
         ams_history_retention_days: localSettings.ams_history_retention_days,
         disable_filament_warnings: localSettings.disable_filament_warnings,
         prefer_lowest_filament: localSettings.prefer_lowest_filament,
@@ -6120,6 +6122,47 @@ export function SettingsPage() {
                   <p className="text-xs text-bambu-gray">
                     {t('settings.aboveFairHot')}
                   </p>
+                  {/* Below the band's own help text, so that line still reads as
+                      describing Fair rather than this field. The comparison lives
+                      in the label string here, not appended as a symbol like the
+                      two above -- most locales word it as "Alarm above", which
+                      would read doubled next to a `>`. */}
+                  <div>
+                    <label className="block text-sm text-bambu-gray mb-1">
+                      {t('settings.tempAlarmThreshold')}
+                    </label>
+                    <div className="flex items-center gap-2">
+                      <input
+                        type="number"
+                        step="0.5"
+                        min="0.5"
+                        max="120"
+                        value={localSettings.ams_temp_alarm ?? ''}
+                        placeholder={String(localSettings.ams_temp_fair ?? 35)}
+                        onChange={(e) => {
+                          const raw = e.target.value.trim();
+                          const parsed = parseFloat(raw);
+                          updateSetting('ams_temp_alarm', raw === '' || Number.isNaN(parsed) ? null : parsed);
+                        }}
+                        className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      />
+                      <span className="text-bambu-gray">°C</span>
+                    </div>
+                  </div>
+                  {/* Warn rather than clamp, for the same reason the humidity
+                      floor above does: clamping a controlled input mid-keystroke
+                      makes "0.5" untypeable, because the "0" would blank the
+                      field before the ".5" arrives. The backend refuses a
+                      non-positive threshold and falls back to Fair, so say so
+                      instead of pretending min= stopped it (#2905). */}
+                  {(localSettings.ams_temp_alarm ?? 1) <= 0 && (
+                    <p className="text-xs text-red-600 dark:text-red-400">
+                      {t('settings.tempAlarmMustBePositive')}
+                    </p>
+                  )}
+                  <p className="text-xs text-amber-700/80 dark:text-amber-400/70">
+                    {t('settings.tempAlarmSeparateFromBand')}
+                  </p>
                 </div>
 
                 {/* History Retention */}

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-YT8DSIaM.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CgFeKbCm.js"></script>
+    <script type="module" crossorigin src="/assets/index-YT8DSIaM.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-C7cOM7tZ.css">
   </head>
   <body>

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác