| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312 |
- """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
|