Files
maziggy 6e8d543d2a fix(ams): stop showing the humidity drop index as a percentage (issue #3140)
Bambu sends two humidity fields that are not the same quantity.
humidity_raw is relative humidity in percent; humidity is a 1-5 drop
index, and it runs the other way -- OpenBambuAPI's push_info sample
pairs "humidity:30%" with "humidity_idx:4", so a high index means dry
where a high percentage means wet.

Four call sites used the index whenever no percentage arrived. A unit
sending only the index therefore rendered as "2%" in the green band
while being the second-wettest of the five steps, charted an average of
index values as a percentage, and sat under every humidity threshold
forever, since no index can reach one -- the alarm and auto-drying could
not fire for such a unit at all.

- utils/ams_humidity: one leaf helper, a percentage or None. The index
  is never converted; None is what every caller already handles.
- routes/printers, printer_manager, print_scheduler, main, bambu_mqtt:
  all five readings go through it, so the card, the websocket, the
  chart, the alarm and auto-drying cannot answer differently.
- main: a unit that reports the index and no usable percentage says so
  once per unit in the log, with its firmware versions requested. No
  supported printer is known to do this, and "known" is doing work
  there -- the alternative is a card that goes blank with no trace.

Three faults found while checking what else those paths touched:

- main: humidity_raw=float(x) if x else None stored NULL for a numeric
  0% while writing 0.0 to humidity on the same row.
- main: that same expression was unguarded, unlike the parse above it,
  so a non-numeric humidity_raw raised inside record_ams_history and
  aborted the pass for every printer, not just the one that sent it.
- routes/ams_history: the averages were tested for truthiness, so a
  window averaging exactly 0 reported no average while the min and max
  beside it reported 0.0.

An affected unit now reports no humidity rather than a number that means
the opposite: the indicator is hidden, the chart leaves a gap, the alarm
and auto-drying skip the unit. Temperature is untouched. Auto-drying's
outcome is unchanged either way -- an index could never cross the
threshold -- so only the intent moves.

No supported printer is known to be affected; the report came from an
install running X1Plus, which Bambuddy does not support. Verified
against 7927 recorded samples from seven AMS units including an AMS-HT:
not one used the fallback. Two percentages that did fall through to the
index no longer do -- a reading with a decimal point, and "38.0", which
int() rejected.
2026-09-24 11:59:30 +02:00

204 lines
8.2 KiB
Python

"""Integration tests for AMS History API endpoints."""
from datetime import datetime, timedelta
import pytest
from httpx import AsyncClient
class TestAMSHistoryAPI:
"""Integration tests for /api/v1/ams-history endpoints."""
@pytest.fixture
async def ams_history_factory(self, db_session, printer_factory):
"""Factory to create test AMS history records."""
async def _create_history(printer_id=None, ams_id=0, **kwargs):
from backend.app.models.ams_history import AMSSensorHistory
if printer_id is None:
printer = await printer_factory()
printer_id = printer.id
defaults = {
"printer_id": printer_id,
"ams_id": ams_id,
"humidity": 45.0,
"humidity_raw": 4500,
"temperature": 25.0,
"recorded_at": datetime.now(),
}
defaults.update(kwargs)
history = AMSSensorHistory(**defaults)
db_session.add(history)
await db_session.commit()
await db_session.refresh(history)
return history
return _create_history
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_ams_history_empty(self, async_client: AsyncClient, printer_factory, db_session):
"""Verify empty history returns empty data array."""
printer = await printer_factory()
response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
assert response.status_code == 200
data = response.json()
assert data["printer_id"] == printer.id
assert data["ams_id"] == 0
assert data["data"] == []
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_ams_history_with_data(self, async_client: AsyncClient, ams_history_factory, db_session):
"""Verify history returns recorded data."""
# Create history records
history = await ams_history_factory()
printer_id = history.printer_id
response = await async_client.get(f"/api/v1/ams-history/{printer_id}/0")
assert response.status_code == 200
data = response.json()
assert len(data["data"]) >= 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_ams_history_with_stats(
self, async_client: AsyncClient, ams_history_factory, printer_factory, db_session
):
"""Verify history includes statistics."""
printer = await printer_factory()
# Create multiple records with different values
await ams_history_factory(printer_id=printer.id, humidity=40.0, temperature=24.0)
await ams_history_factory(printer_id=printer.id, humidity=50.0, temperature=26.0)
await ams_history_factory(printer_id=printer.id, humidity=45.0, temperature=25.0)
response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
assert response.status_code == 200
data = response.json()
# Check statistics
assert data["min_humidity"] == 40.0
assert data["max_humidity"] == 50.0
assert data["min_temperature"] == 24.0
assert data["max_temperature"] == 26.0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_an_average_of_zero_is_reported_as_zero(
self, async_client: AsyncClient, ams_history_factory, printer_factory, db_session
):
"""A window whose readings are all 0 has an average of 0, not "no data".
The response used to test the average for truthiness, so min and max
reported 0.0 while the average beside them came back null and the card
showed an em dash (#3140). Zero is rare but real -- a warm unit part way
through a drying cycle reaches it.
"""
printer = await printer_factory()
await ams_history_factory(printer_id=printer.id, humidity=0.0, temperature=0.0)
await ams_history_factory(printer_id=printer.id, humidity=0.0, temperature=0.0)
response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
assert response.status_code == 200
data = response.json()
assert data["min_humidity"] == 0.0
assert data["avg_humidity"] == 0.0
assert data["avg_temperature"] == 0.0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_an_empty_window_still_has_no_average(self, async_client: AsyncClient, printer_factory):
"""The one case that genuinely has no answer must stay null."""
printer = await printer_factory()
response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
assert response.status_code == 200
data = response.json()
assert data["data"] == []
assert data["avg_humidity"] is None
assert data["avg_temperature"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_ams_history_with_hours_filter(
self, async_client: AsyncClient, ams_history_factory, printer_factory, db_session
):
"""Verify hours parameter filters data."""
printer = await printer_factory()
# Create a recent record
await ams_history_factory(printer_id=printer.id, recorded_at=datetime.now())
# Create an old record (outside default 24h)
await ams_history_factory(printer_id=printer.id, recorded_at=datetime.now() - timedelta(hours=48))
# Request only last 24 hours (default)
response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
assert response.status_code == 200
data = response.json()
# Should only get the recent record
assert len(data["data"]) == 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_ams_history_custom_hours(self, async_client: AsyncClient, printer_factory, db_session):
"""Verify custom hours parameter works."""
printer = await printer_factory()
response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0", params={"hours": 48})
assert response.status_code == 200
data = response.json()
assert data["printer_id"] == printer.id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_ams_history_different_ams_units(
self, async_client: AsyncClient, ams_history_factory, printer_factory, db_session
):
"""Verify filtering by AMS unit ID."""
printer = await printer_factory()
await ams_history_factory(printer_id=printer.id, ams_id=0, humidity=40.0)
await ams_history_factory(printer_id=printer.id, ams_id=1, humidity=50.0)
# Get AMS unit 0
response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
assert response.status_code == 200
data0 = response.json()
assert len(data0["data"]) == 1
assert data0["data"][0]["humidity"] == 40.0
# Get AMS unit 1
response = await async_client.get(f"/api/v1/ams-history/{printer.id}/1")
assert response.status_code == 200
data1 = response.json()
assert len(data1["data"]) == 1
assert data1["data"][0]["humidity"] == 50.0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_old_history(
self, async_client: AsyncClient, ams_history_factory, printer_factory, db_session
):
"""Verify old history can be deleted."""
printer = await printer_factory()
# Create an old record
await ams_history_factory(printer_id=printer.id, recorded_at=datetime.now() - timedelta(days=60))
# Delete records older than 30 days
response = await async_client.delete(f"/api/v1/ams-history/{printer.id}", params={"days": 30})
assert response.status_code == 200
data = response.json()
assert data["deleted"] >= 1
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_old_history_no_records(self, async_client: AsyncClient, printer_factory, db_session):
"""Verify delete with no old records returns 0."""
printer = await printer_factory()
response = await async_client.delete(f"/api/v1/ams-history/{printer.id}", params={"days": 30})
assert response.status_code == 200
data = response.json()
assert data["deleted"] == 0