Files
bambuddy/backend/app/api/routes/ams_history.py
T
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

138 lines
4.6 KiB
Python

"""API routes for AMS sensor history."""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.ams_history import AMSSensorHistory
from backend.app.models.user import User
router = APIRouter(prefix="/ams-history", tags=["ams-history"])
class AMSHistoryPoint(BaseModel):
recorded_at: datetime
humidity: float | None
humidity_raw: float | None
temperature: float | None
class AMSHistoryResponse(BaseModel):
printer_id: int
ams_id: int
data: list[AMSHistoryPoint]
min_humidity: float | None
max_humidity: float | None
avg_humidity: float | None
min_temperature: float | None
max_temperature: float | None
avg_temperature: float | None
@router.get("/{printer_id}/{ams_id}", response_model=AMSHistoryResponse)
async def get_ams_history(
printer_id: int,
ams_id: int,
hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
):
"""Get AMS sensor history for a specific printer and AMS unit."""
since = datetime.now(timezone.utc) - timedelta(hours=hours)
# Get data points
result = await db.execute(
select(AMSSensorHistory)
.where(
and_(
AMSSensorHistory.printer_id == printer_id,
AMSSensorHistory.ams_id == ams_id,
AMSSensorHistory.recorded_at >= since,
)
)
.order_by(AMSSensorHistory.recorded_at)
)
records = result.scalars().all()
# Calculate stats
stats_result = await db.execute(
select(
func.min(AMSSensorHistory.humidity).label("min_humidity"),
func.max(AMSSensorHistory.humidity).label("max_humidity"),
func.avg(AMSSensorHistory.humidity).label("avg_humidity"),
func.min(AMSSensorHistory.temperature).label("min_temp"),
func.max(AMSSensorHistory.temperature).label("max_temp"),
func.avg(AMSSensorHistory.temperature).label("avg_temp"),
).where(
and_(
AMSSensorHistory.printer_id == printer_id,
AMSSensorHistory.ams_id == ams_id,
AMSSensorHistory.recorded_at >= since,
)
)
)
stats = stats_result.one()
return AMSHistoryResponse(
printer_id=printer_id,
ams_id=ams_id,
data=[
AMSHistoryPoint(
recorded_at=r.recorded_at,
humidity=r.humidity,
humidity_raw=r.humidity_raw,
temperature=r.temperature,
)
for r in records
],
min_humidity=stats.min_humidity,
max_humidity=stats.max_humidity,
# ``is not None``, not truthiness: an average of exactly 0 is a
# reading, and the min/max beside it would report it while the average
# showed an em dash. AVG over an empty or all-NULL window is the only
# case that has no answer (#3140).
avg_humidity=round(stats.avg_humidity, 1) if stats.avg_humidity is not None else None,
min_temperature=stats.min_temp,
max_temperature=stats.max_temp,
avg_temperature=round(stats.avg_temp, 1) if stats.avg_temp is not None else None,
)
@router.delete("/{printer_id}")
async def delete_old_history(
printer_id: int,
days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
):
"""Delete old AMS history data for a printer."""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
result = await db.execute(
select(func.count(AMSSensorHistory.id)).where(
and_(
AMSSensorHistory.printer_id == printer_id,
AMSSensorHistory.recorded_at < cutoff,
)
)
)
count = result.scalar()
await db.execute(
AMSSensorHistory.__table__.delete().where(
and_(
AMSSensorHistory.printer_id == printer_id,
AMSSensorHistory.recorded_at < cutoff,
)
)
)
await db.commit()
return {"deleted": count, "message": f"Deleted {count} records older than {days} days"}