|
@@ -0,0 +1,312 @@
|
|
|
|
|
+"""The 1-5 humidity index is never shown or stored as a percentage (#3140).
|
|
|
|
|
+
|
|
|
|
|
+Bambu sends ``humidity_raw`` (relative humidity, percent) and ``humidity`` (a
|
|
|
|
|
+1-5 drop index). The index runs the other way -- OpenBambuAPI's push_info
|
|
|
|
|
+sample pairs ``humidity:30%`` with ``humidity_idx:4`` -- so substituting one
|
|
|
|
|
+for the other inverts the reading rather than approximating it. A unit sending
|
|
|
|
|
+only the index used to render as "2%" in the good/green band while being the
|
|
|
|
|
+second-wettest of the five steps, chart an average of index values as a
|
|
|
|
|
+percentage, and sit under every alarm threshold forever.
|
|
|
|
|
+
|
|
|
|
|
+The reporting install ran X1Plus, which Bambuddy does not support, and no
|
|
|
|
|
+stock-firmware printer is on record as sending the index alone. The guard is
|
|
|
|
|
+kept regardless because it is about what we do when the field is missing for
|
|
|
|
|
+any reason, and showing a number we cannot interpret is worse than showing
|
|
|
|
|
+none: every consumer of these serializers already handles ``None`` by hiding
|
|
|
|
|
+the indicator, skipping the unit or leaving a gap in the chart.
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+import asyncio
|
|
|
|
|
+import logging
|
|
|
|
|
+from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
+
|
|
|
|
|
+import pytest
|
|
|
|
|
+from sqlalchemy import select
|
|
|
|
|
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
|
+
|
|
|
|
|
+import backend.app.main as main
|
|
|
|
|
+from backend.app.api.routes.printers import get_printer_status
|
|
|
|
|
+from backend.app.models.ams_history import AMSSensorHistory
|
|
|
|
|
+from backend.app.models.printer import Printer
|
|
|
|
|
+from backend.app.services.bambu_mqtt import PrinterState
|
|
|
|
|
+from backend.app.services.printer_manager import printer_state_to_dict
|
|
|
|
|
+from backend.app.utils.ams_humidity import ams_humidity_percent
|
|
|
|
|
+
|
|
|
|
|
+# --- the reading itself ---
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_a_reported_percentage_is_the_reading():
|
|
|
|
|
+ assert ams_humidity_percent({"humidity_raw": 45}) == 45.0
|
|
|
|
|
+ assert ams_humidity_percent({"humidity_raw": "45"}) == 45.0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_a_fractional_percentage_survives():
|
|
|
|
|
+ """``int("16.5")`` raised, which sent the old code to the index fallback --
|
|
|
|
|
+ so a sensor reporting one decimal place read as a drop index."""
|
|
|
|
|
+ assert ams_humidity_percent({"humidity_raw": "16.5"}) == 16.5
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_the_index_alone_is_not_a_reading():
|
|
|
|
|
+ """The guard. Index 2 is the second-wettest step, and 2% is as dry as a
|
|
|
|
|
+ unit can read -- the two are not interchangeable in either direction."""
|
|
|
|
|
+ assert ams_humidity_percent({"humidity": 2}) is None
|
|
|
|
|
+ assert ams_humidity_percent({"humidity": "2"}) is None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_the_percentage_wins_when_both_are_present():
|
|
|
|
|
+ assert ams_humidity_percent({"humidity": 4, "humidity_raw": "62"}) == 62.0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_a_genuine_zero_is_a_reading():
|
|
|
|
|
+ """Not ``None``: the history writer used to test truthiness, so a unit
|
|
|
|
|
+ reading 0% stored NULL while the same pass wrote 0.0 to the other column."""
|
|
|
|
|
+ assert ams_humidity_percent({"humidity_raw": 0}) == 0.0
|
|
|
|
|
+ assert ams_humidity_percent({"humidity_raw": "0"}) == 0.0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_an_unparseable_percentage_is_not_a_licence_to_use_the_index():
|
|
|
|
|
+ assert ams_humidity_percent({"humidity_raw": "n/a", "humidity": 3}) is None
|
|
|
|
|
+ assert ams_humidity_percent({"humidity_raw": None, "humidity": 3}) is None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_a_unit_that_is_not_a_mapping_reads_as_no_unit():
|
|
|
|
|
+ assert ams_humidity_percent(None) is None
|
|
|
|
|
+ assert ams_humidity_percent("ams0") is None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# --- what the two serializers of the same card report ---
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _index_only_unit() -> dict:
|
|
|
|
|
+ return {
|
|
|
|
|
+ "ams": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": 0,
|
|
|
|
|
+ "humidity": "2", # index, no humidity_raw
|
|
|
|
|
+ "temp": "24.0",
|
|
|
|
|
+ "tray": [{"id": 0, "tray_type": "PLA"}],
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def test_the_websocket_serializer_reports_no_humidity_for_an_index_only_unit():
|
|
|
|
|
+ result = printer_state_to_dict(PrinterState(connected=True, state="IDLE", raw_data=_index_only_unit()))
|
|
|
|
|
+
|
|
|
|
|
+ assert result["ams"][0]["humidity"] is None
|
|
|
|
|
+ assert result["ams"][0]["temp"] == "24.0" # the other sensor is unaffected
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_the_rest_serializer_reports_no_humidity_for_an_index_only_unit(db_session):
|
|
|
|
|
+ """The two serializers feed the same card and must not answer differently."""
|
|
|
|
|
+ printer = Printer(name="X1C", serial_number="S-3140", ip_address="1.1.1.1", access_code="c", model="X1C")
|
|
|
|
|
+ db_session.add(printer)
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+
|
|
|
|
|
+ state = PrinterState(connected=True, state="IDLE", raw_data=_index_only_unit())
|
|
|
|
|
+
|
|
|
|
|
+ with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
|
|
|
|
|
+ mock_pm.get_status.return_value = state
|
|
|
|
|
+ mock_pm.get_drying_targets.return_value = {}
|
|
|
|
|
+ status = await get_printer_status(printer.id, db=db_session)
|
|
|
|
|
+
|
|
|
|
|
+ assert status.ams[0].humidity is None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_the_rest_serializer_still_reports_a_percentage(db_session):
|
|
|
|
|
+ """The guard must not cost the supported case its reading."""
|
|
|
|
|
+ printer = Printer(name="H2D", serial_number="S-3140b", ip_address="1.1.1.2", access_code="c", model="H2D")
|
|
|
|
|
+ db_session.add(printer)
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+
|
|
|
|
|
+ raw = _index_only_unit()
|
|
|
|
|
+ raw["ams"][0]["humidity_raw"] = "38"
|
|
|
|
|
+ state = PrinterState(connected=True, state="IDLE", raw_data=raw)
|
|
|
|
|
+
|
|
|
|
|
+ with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
|
|
|
|
|
+ mock_pm.get_status.return_value = state
|
|
|
|
|
+ mock_pm.get_drying_targets.return_value = {}
|
|
|
|
|
+ status = await get_printer_status(printer.id, db=db_session)
|
|
|
|
|
+
|
|
|
|
|
+ assert status.ams[0].humidity == 38
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# --- what the recorder writes, and what it alarms on ---
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _state_with(unit: dict) -> PrinterState:
|
|
|
|
|
+ return PrinterState(connected=True, state="IDLE", raw_data={"ams": [unit]})
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _run_one_pass(test_engine, unit: dict):
|
|
|
|
|
+ """One pass of record_ams_history against a single AMS unit.
|
|
|
|
|
+
|
|
|
|
|
+ Same shape as test_ams_temp_alarm_dispatch_2905: the loop is a no-arg
|
|
|
|
|
+ infinite task, so a fake sleep that recognises its own intervals runs
|
|
|
|
|
+ exactly one pass and then cancels it.
|
|
|
|
|
+ """
|
|
|
|
|
+ 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_humidity_high = AsyncMock()
|
|
|
|
|
+ service.on_ams_ht_humidity_high = AsyncMock()
|
|
|
|
|
+ service.on_ams_temperature_high = AsyncMock()
|
|
|
|
|
+ service.on_ams_ht_temperature_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=_state_with(unit)),
|
|
|
|
|
+ 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 _rows(db_session, printer_id: int) -> list[AMSSensorHistory]:
|
|
|
|
|
+ result = await db_session.execute(select(AMSSensorHistory).where(AMSSensorHistory.printer_id == printer_id))
|
|
|
|
|
+ return list(result.scalars().all())
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _printer(db_session, serial: str) -> Printer:
|
|
|
|
|
+ printer = Printer(name="X1C", serial_number=serial, ip_address="1.1.1.1", access_code="c", model="X1C")
|
|
|
|
|
+ db_session.add(printer)
|
|
|
|
|
+ await db_session.commit()
|
|
|
|
|
+ return printer
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_an_index_only_unit_charts_a_gap_not_a_percentage(db_session, test_engine):
|
|
|
|
|
+ """The temperature is still worth recording, so the row is written -- but
|
|
|
|
|
+ with no humidity, which the chart draws as a gap rather than as a flat 2%
|
|
|
|
|
+ line in the good band."""
|
|
|
|
|
+ printer = await _printer(db_session, "S-3140c")
|
|
|
|
|
+
|
|
|
|
|
+ service = await _run_one_pass(
|
|
|
|
|
+ test_engine,
|
|
|
|
|
+ {"id": 0, "humidity": "2", "temp": "24.0", "tray_exist_bits": "1", "tray": [{"tray_type": "PLA"}]},
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ rows = await _rows(db_session, printer.id)
|
|
|
|
|
+ assert len(rows) == 1
|
|
|
|
|
+ assert rows[0].humidity is None
|
|
|
|
|
+ assert rows[0].humidity_raw is None
|
|
|
|
|
+ assert rows[0].temperature == 24.0
|
|
|
|
|
+ assert service.on_ams_humidity_high.await_count == 0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_an_index_only_unit_says_so_in_the_log_once(db_session, test_engine, caplog):
|
|
|
|
|
+ """A blank humidity field on a supported printer would otherwise be silent.
|
|
|
|
|
+
|
|
|
|
|
+ Nothing on record says a supported printer sends the index alone, and the
|
|
|
|
|
+ guard makes such a unit stop reporting humidity entirely -- so it names
|
|
|
|
|
+ itself in the log, once per unit, rather than leaving the card blank with
|
|
|
|
|
+ no explanation anywhere.
|
|
|
|
|
+ """
|
|
|
|
|
+ printer = await _printer(db_session, "S-3140f")
|
|
|
|
|
+ main._ams_index_only_logged.clear()
|
|
|
|
|
+ unit = {"id": 0, "humidity": "2", "temp": "24.0", "tray_exist_bits": "1", "tray": [{"tray_type": "PLA"}]}
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ with caplog.at_level(logging.INFO, logger="backend.app.main"):
|
|
|
|
|
+ await _run_one_pass(test_engine, unit)
|
|
|
|
|
+ await _run_one_pass(test_engine, unit)
|
|
|
|
|
+ finally:
|
|
|
|
|
+ main._ams_index_only_logged.clear()
|
|
|
|
|
+
|
|
|
|
|
+ lines = [r.getMessage() for r in caplog.records if "humidity index" in r.getMessage()]
|
|
|
|
|
+ assert len(lines) == 1
|
|
|
|
|
+ assert printer.name in lines[0]
|
|
|
|
|
+ assert "#3140" in lines[0]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_a_unit_reporting_no_humidity_at_all_is_not_logged(db_session, test_engine, caplog):
|
|
|
|
|
+ """The note is about a unit whose index we are declining to use. A unit
|
|
|
|
|
+ that sends neither field is not new and has nothing to report."""
|
|
|
|
|
+ await _printer(db_session, "S-3140g")
|
|
|
|
|
+ main._ams_index_only_logged.clear()
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ with caplog.at_level(logging.INFO, logger="backend.app.main"):
|
|
|
|
|
+ await _run_one_pass(
|
|
|
|
|
+ test_engine,
|
|
|
|
|
+ {"id": 0, "temp": "24.0", "tray_exist_bits": "1", "tray": [{"tray_type": "PLA"}]},
|
|
|
|
|
+ )
|
|
|
|
|
+ finally:
|
|
|
|
|
+ main._ams_index_only_logged.clear()
|
|
|
|
|
+
|
|
|
|
|
+ assert not [r for r in caplog.records if "humidity index" in r.getMessage()]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_a_zero_percent_reading_is_stored_in_both_columns(db_session, test_engine):
|
|
|
|
|
+ """``float(raw) if raw else None`` stored NULL for a genuine 0%, while the
|
|
|
|
|
+ same pass wrote 0.0 to ``humidity`` -- one row disagreeing with itself.
|
|
|
|
|
+
|
|
|
|
|
+ Numeric 0, not "0": the truthiness test only swallowed the reading when the
|
|
|
|
|
+ firmware sent the value as a number, which is how the defect survived the
|
|
|
|
|
+ string-valued samples every other test here uses."""
|
|
|
|
|
+ printer = await _printer(db_session, "S-3140d")
|
|
|
|
|
+
|
|
|
|
|
+ await _run_one_pass(
|
|
|
|
|
+ test_engine,
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": 0,
|
|
|
|
|
+ "humidity": "5",
|
|
|
|
|
+ "humidity_raw": 0,
|
|
|
|
|
+ "temp": "24.0",
|
|
|
|
|
+ "tray_exist_bits": "1",
|
|
|
|
|
+ "tray": [{"tray_type": "PLA"}],
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ rows = await _rows(db_session, printer.id)
|
|
|
|
|
+ assert len(rows) == 1
|
|
|
|
|
+ assert rows[0].humidity == 0.0
|
|
|
|
|
+ assert rows[0].humidity_raw == 0.0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
|
|
+async def test_a_reported_percentage_still_alarms(db_session, test_engine):
|
|
|
|
|
+ """The supported path, asserted alongside the guard so a regression that
|
|
|
|
|
+ silenced every humidity alarm could not pass as the fix."""
|
|
|
|
|
+ printer = await _printer(db_session, "S-3140e")
|
|
|
|
|
+
|
|
|
|
|
+ service = await _run_one_pass(
|
|
|
|
|
+ test_engine,
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": 0,
|
|
|
|
|
+ "humidity": "1",
|
|
|
|
|
+ "humidity_raw": "80",
|
|
|
|
|
+ "temp": "24.0",
|
|
|
|
|
+ "tray_exist_bits": "1",
|
|
|
|
|
+ "tray": [{"tray_type": "PLA"}],
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ service.on_ams_humidity_high.assert_awaited_once()
|
|
|
|
|
+ assert service.on_ams_humidity_high.await_args.args[3] == 80.0
|
|
|
|
|
+ rows = await _rows(db_session, printer.id)
|
|
|
|
|
+ assert rows[0].humidity == 80.0
|