power_alerts.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. """Smart plug power alerts.
  2. A plug with power alerts on is read in the background (smart_plug_manager), so
  3. an alert arrives without anyone having Bambuddy open. An alert is sent when the
  4. power crosses from inside the range to above the high value or below the low
  5. value, and again only after it has been back inside the range: a printer idling
  6. below a low threshold is reported once, not every few minutes. The low value is
  7. only checked while the plug reports itself switched on, so turning a printer
  8. off raises no "power low" alert; a plug that can't report its switch state
  9. (a REST plug without a status URL, an MQTT plug with only a power topic) gets
  10. no low alerts at all.
  11. """
  12. import logging
  13. import time
  14. from datetime import timedelta
  15. from sqlalchemy import select
  16. from sqlalchemy.ext.asyncio import AsyncSession
  17. from backend.app.models.smart_plug import SmartPlug
  18. from backend.app.services.notification_service import notification_service
  19. from backend.app.services.plug_energy import read_plug
  20. from backend.app.utils.local_time import to_naive_utc, utcnow_naive
  21. logger = logging.getLogger(__name__)
  22. # Minimum time between two alerts of one plug, so a value hovering at the
  23. # threshold doesn't send one alert per check.
  24. COOLDOWN = timedelta(minutes=5)
  25. # How long an unreachable plug is left alone before it is polled again, so a
  26. # plug that is unplugged doesn't log a connection warning every minute.
  27. UNREACHABLE_RETRY_SECONDS = 600
  28. class PowerAlertMonitor:
  29. """Remembers per plug whether the power was last above, below or inside the range.
  30. Kept in memory: after a restart a plug that is still outside its range
  31. alerts once more.
  32. """
  33. def __init__(self) -> None:
  34. self._outside: dict[int, str | None] = {}
  35. self._retry_at: dict[int, float] = {}
  36. def forget_all_but(self, plug_ids: set[int]) -> None:
  37. """Drop what is known about plugs no longer checked (alerts off, deleted)."""
  38. for known in (self._outside, self._retry_at):
  39. for plug_id in set(known) - plug_ids:
  40. del known[plug_id]
  41. def is_resting(self, plug_id: int) -> bool:
  42. return time.monotonic() < self._retry_at.get(plug_id, 0.0)
  43. def mark_unreachable(self, plug_id: int) -> None:
  44. self._retry_at[plug_id] = time.monotonic() + UNREACHABLE_RETRY_SECONDS
  45. async def evaluate(self, plug: SmartPlug, state: str | None, power: float | None, db: AsyncSession) -> None:
  46. self._retry_at.pop(plug.id, None)
  47. if not plug.power_alert_enabled or power is None:
  48. return
  49. if state == "OFF":
  50. self._outside[plug.id] = None
  51. return
  52. if plug.power_alert_high is not None and power > plug.power_alert_high:
  53. level, threshold = "high", plug.power_alert_high
  54. elif plug.power_alert_low is not None and power < plug.power_alert_low and state == "ON":
  55. level, threshold = "low", plug.power_alert_low
  56. else:
  57. self._outside[plug.id] = None
  58. return
  59. if self._outside.get(plug.id) == level:
  60. return
  61. last = to_naive_utc(plug.power_alert_last_triggered)
  62. if last is not None and utcnow_naive() - last < COOLDOWN:
  63. # Not recorded as seen: if the power is still out of range once the
  64. # cooldown has passed, the alert goes out then.
  65. return
  66. plug_id, plug_name, printer_id = plug.id, plug.name, plug.printer_id
  67. plug.power_alert_last_triggered = utcnow_naive()
  68. await db.commit()
  69. direction = "above" if level == "high" else "below"
  70. detail = f"Power consumption is {power:.1f}W, {direction} threshold of {threshold:.1f}W"
  71. logger.info("Power alert for plug %s: %s", plug_name, detail)
  72. await notification_service.on_printer_error(
  73. printer_id=printer_id,
  74. printer_name=plug_name,
  75. error_type=f"Power {level.title()}",
  76. db=db,
  77. error_detail=detail,
  78. )
  79. # Recorded only once it has gone out: a failed write or send is retried
  80. # on a later pass (after the cooldown, if the write got through).
  81. self._outside[plug_id] = level
  82. power_alert_monitor = PowerAlertMonitor()
  83. async def check_all_plugs(db: AsyncSession, monitor: PowerAlertMonitor = power_alert_monitor) -> None:
  84. """Read every plug with power alerts on and alert on any crossing.
  85. One plug failing to answer, or failing to save, doesn't stop the others.
  86. """
  87. plugs = (await db.execute(select(SmartPlug).where(SmartPlug.power_alert_enabled.is_(True)))).scalars().all()
  88. checked = [
  89. (plug, plug.id, plug.name)
  90. for plug in plugs
  91. if plug.power_alert_high is not None or plug.power_alert_low is not None
  92. ]
  93. monitor.forget_all_but({plug_id for _, plug_id, _ in checked})
  94. rolled_back = False
  95. for plug, plug_id, plug_name in checked:
  96. if monitor.is_resting(plug_id):
  97. continue
  98. try:
  99. if rolled_back:
  100. # A rollback expires every loaded row; reading one lazily on an
  101. # async session would fail.
  102. await db.refresh(plug)
  103. state, power = await read_plug(plug, db)
  104. if state is None and power is None:
  105. # MQTT plugs are read from memory: nothing to spare by pausing,
  106. # and one that hasn't published yet would be ignored for minutes.
  107. if plug.plug_type != "mqtt":
  108. monitor.mark_unreachable(plug_id)
  109. continue
  110. await monitor.evaluate(plug, state, power, db)
  111. except Exception as e:
  112. # A failed write leaves the session needing a rollback; without it
  113. # every plug after this one would fail on the same session.
  114. await db.rollback()
  115. rolled_back = True
  116. logger.warning("Power alert check failed for plug %s: %s", plug_name, e)