Просмотр исходного кода

fix(smart-plugs): power alerts are sent, checked in the background, once per crossing

maziggy 1 неделя назад
Родитель
Сommit
47795649ae

+ 0 - 26
.basedpyright/baseline.json

@@ -3342,32 +3342,6 @@
                 }
                 }
             }
             }
         ],
         ],
-        "./backend/app/api/routes/smart_plugs.py": [
-            {
-                "code": "reportOperatorIssue",
-                "range": {
-                    "startColumn": 26,
-                    "endColumn": 88,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportAttributeAccessIssue",
-                "range": {
-                    "startColumn": 35,
-                    "endColumn": 52,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportOptionalMemberAccess",
-                "range": {
-                    "startColumn": 50,
-                    "endColumn": 55,
-                    "lineCount": 1
-                }
-            }
-        ],
         "./backend/app/api/routes/spoolbuddy.py": [
         "./backend/app/api/routes/spoolbuddy.py": [
             {
             {
                 "code": "reportAssignmentType",
                 "code": "reportAssignmentType",

+ 3 - 0
CHANGELOG.md

@@ -161,6 +161,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 
 ### Fixed
 ### Fixed
+- **Smart plug power alerts were never sent** — A plug's **Power Alerts** (alert if the power is above or below a value) never reached any notification provider, and each time one was due, that request for the plug's status on the printer card failed. They also were only checked while someone had a printer page open.
+  - **Now checked in the background,** about once a minute, whether or not Bambuddy is open.
+  - **One alert per crossing:** you're told when the power leaves the range, and again only after it was back inside it, so a printer idling below a low value is reported once rather than every five minutes. The low value only counts while the plug reports itself switched on, so turning a printer off raises no alert (a plug that can't report its switch state gets no low alerts). Alerts go out as the **Printer Error** event ("Printer Error: Power High" / "Power Low"). Wiki updated.
 - **A project's timeline stayed empty while one of its jobs was queued or printing** — The **Timeline** on a project's page failed to load as soon as the project had a pending or printing job in the queue, so it showed no events at all, not even the finished prints. It loads again, and queued and running jobs show under their print name (or file name; a job for any of several printer models under its first file).
 - **A project's timeline stayed empty while one of its jobs was queued or printing** — The **Timeline** on a project's page failed to load as soon as the project had a pending or printing job in the queue, so it showed no events at all, not even the finished prints. It loads again, and queued and running jobs show under their print name (or file name; a job for any of several printer models under its first file).
 - **Stopping or cancelling a print through the API failed with an error** — `POST /api/v1/webhook/printer/{id}/cancel` answered with an error and left the print running. `POST /api/v1/webhook/printer/{id}/stop` stopped the print but still answered with an error. This affected Home Assistant automations and scripts that use an API key; the Stop button in Bambuddy was not affected.
 - **Stopping or cancelling a print through the API failed with an error** — `POST /api/v1/webhook/printer/{id}/cancel` answered with an error and left the print running. `POST /api/v1/webhook/printer/{id}/stop` stopped the print but still answered with an error. This affected Home Assistant automations and scripts that use an API key; the Stop button in Bambuddy was not affected.
   - **Now both stop the print and answer normally.** If the printer drops off before the command can be sent, they answer "Printer not connected" (503).
   - **Now both stop the print and answer normally.** If the printer drops off before the command can be sent, they answer "Printer not connected" (503).

+ 3 - 67
backend/app/api/routes/smart_plugs.py

@@ -1,7 +1,6 @@
 """API routes for smart plug management."""
 """API routes for smart plug management."""
 
 
 import logging
 import logging
-from datetime import timedelta
 
 
 from fastapi import APIRouter, Body, Depends, HTTPException
 from fastapi import APIRouter, Body, Depends, HTTPException
 from pydantic import BaseModel
 from pydantic import BaseModel
@@ -40,12 +39,11 @@ from backend.app.services.discovery import tasmota_scanner
 from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt
 from backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt
-from backend.app.services.notification_service import notification_service
 from backend.app.services.plug_energy_history import fill_derived_energy
 from backend.app.services.plug_energy_history import fill_derived_energy
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.rest_smart_plug import rest_smart_plug_service
 from backend.app.services.rest_smart_plug import rest_smart_plug_service
 from backend.app.services.tasmota import tasmota_service
 from backend.app.services.tasmota import tasmota_service
-from backend.app.utils.local_time import to_naive_utc, utcnow_naive
+from backend.app.utils.local_time import utcnow_naive
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -784,9 +782,6 @@ async def get_plug_status(
                     power=data.power,
                     power=data.power,
                     today=data.energy,
                     today=data.energy,
                 )
                 )
-                # Check power alerts
-                if data.power is not None:
-                    await check_power_alerts(plug, data.power, db)
 
 
             return SmartPlugStatus(
             return SmartPlugStatus(
                 state=data.state,
                 state=data.state,
@@ -824,9 +819,8 @@ async def get_plug_status(
             # knows its own daily figures, is left alone.
             # knows its own daily figures, is left alone.
             energy = await fill_derived_energy(db, plug.id, energy)
             energy = await fill_derived_energy(db, plug.id, energy)
             energy_data = SmartPlugEnergy(**energy)
             energy_data = SmartPlugEnergy(**energy)
-
-            # Check power alerts
-            await check_power_alerts(plug, energy.get("power"), db)
+            # Power alerts are checked in the background (services/power_alerts.py),
+            # not here: this is only read while someone has a printer page open.
 
 
     return SmartPlugStatus(
     return SmartPlugStatus(
         state=status["state"],
         state=status["state"],
@@ -836,64 +830,6 @@ async def get_plug_status(
     )
     )
 
 
 
 
-async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: AsyncSession):
-    """Check if power crosses alert thresholds and send notifications."""
-    if not plug.power_alert_enabled or current_power is None:
-        return
-
-    # Cooldown: don't alert more than once per 5 minutes
-    cooldown_minutes = 5
-    if plug.power_alert_last_triggered:
-        # Naive UTC on both sides: the column is naive, so a row loaded fresh from
-        # the DB comes back without an offset and subtracting an aware now() would
-        # raise TypeError.
-        time_since_last = utcnow_naive() - to_naive_utc(plug.power_alert_last_triggered)
-        if time_since_last < timedelta(minutes=cooldown_minutes):
-            return
-
-    alert_triggered = False
-    alert_type = None
-    threshold = None
-
-    # Check high threshold
-    if plug.power_alert_high is not None and current_power > plug.power_alert_high:
-        alert_triggered = True
-        alert_type = "high"
-        threshold = plug.power_alert_high
-
-    # Check low threshold
-    if plug.power_alert_low is not None and current_power < plug.power_alert_low:
-        alert_triggered = True
-        alert_type = "low"
-        threshold = plug.power_alert_low
-
-    if alert_triggered:
-        plug.power_alert_last_triggered = utcnow_naive()
-        await db.commit()
-
-        # Send notification
-        title = f"Power Alert: {plug.name}"
-        if alert_type == "high":
-            message = f"Power consumption is {current_power:.1f}W, above threshold of {threshold:.1f}W"
-        else:
-            message = f"Power consumption is {current_power:.1f}W, below threshold of {threshold:.1f}W"
-
-        logger.info("Power alert triggered for %s: %s", plug.name, message)
-
-        # Use printer_error event type for power alerts (closest match)
-        await notification_service.send_notification(
-            event_type="printer_error",
-            title=title,
-            message=message,
-            printer_id=plug.printer_id,
-            printer_name=plug.name,
-            context={
-                "error_type": f"Power {alert_type.title()}",
-                "error_detail": message,
-            },
-        )
-
-
 @router.post("/test-connection")
 @router.post("/test-connection")
 async def test_connection(
 async def test_connection(
     data: SmartPlugTestConnection,
     data: SmartPlugTestConnection,

+ 1 - 34
backend/app/main.py

@@ -115,7 +115,6 @@ from backend.app.services.camera_light import camera_light
 from backend.app.services.energy_plug import energy_plug_candidates, select_energy_reading
 from backend.app.services.energy_plug import energy_plug_candidates, select_energy_reading
 from backend.app.services.github_backup import github_backup_service
 from backend.app.services.github_backup import github_backup_service
 from backend.app.services.ha_sensor_manager import ha_sensor_manager
 from backend.app.services.ha_sensor_manager import ha_sensor_manager
-from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.library_trash import library_trash_service
 from backend.app.services.library_trash import library_trash_service
 from backend.app.services.local_backup import local_backup_service
 from backend.app.services.local_backup import local_backup_service
 from backend.app.services.location_ha_sensor_manager import location_ha_sensor_manager
 from backend.app.services.location_ha_sensor_manager import location_ha_sensor_manager
@@ -125,6 +124,7 @@ from backend.app.services.notification_service import notification_service
 from backend.app.services.notify_live_activities import notify_live_activities
 from backend.app.services.notify_live_activities import notify_live_activities
 from backend.app.services.notify_widgets import notify_widgets
 from backend.app.services.notify_widgets import notify_widgets
 from backend.app.services.obico_detection import obico_detection_service
 from backend.app.services.obico_detection import obico_detection_service
+from backend.app.services.plug_energy import get_plug_energy as _get_plug_energy
 from backend.app.services.print_cost_estimate import plate_scoped_run_estimate as _plate_scoped_run_estimate
 from backend.app.services.print_cost_estimate import plate_scoped_run_estimate as _plate_scoped_run_estimate
 from backend.app.services.print_scheduler import scheduler as print_scheduler
 from backend.app.services.print_scheduler import scheduler as print_scheduler
 from backend.app.services.print_storage import (
 from backend.app.services.print_storage import (
@@ -158,7 +158,6 @@ from backend.app.services.spoolman_tracking import (
     report_usage as _report_spoolman_usage,
     report_usage as _report_spoolman_usage,
     store_print_data as _store_spoolman_print_data,
     store_print_data as _store_spoolman_print_data,
 )
 )
-from backend.app.services.tasmota import tasmota_service
 from backend.app.services.telegram_reactions import telegram_reaction_poller
 from backend.app.services.telegram_reactions import telegram_reaction_poller
 from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
 from backend.app.utils.ams_drying import is_drying_active, temperature_alarm_suppressed
 from backend.app.utils.ams_humidity import ams_humidity_percent
 from backend.app.utils.ams_humidity import ams_humidity_percent
@@ -948,38 +947,6 @@ async def _kill_switch_notification_already_sent(task: asyncio.Task[bool] | None
         return False
         return False
 
 
 
 
-async def _get_plug_energy(plug, db) -> dict | None:
-    """Get energy from plug regardless of type (Tasmota, Home Assistant, MQTT, or REST).
-
-    For HA plugs, configures the service with current settings from DB.
-    For MQTT plugs, returns data from the subscription service.
-    For REST plugs, polls the status URL with JSON path extraction.
-    """
-    if plug.plug_type == "homeassistant":
-        from backend.app.api.routes.settings import get_homeassistant_settings
-
-        ha_settings = await get_homeassistant_settings(db)
-        homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
-        return await homeassistant_service.get_energy(plug)
-    elif plug.plug_type == "mqtt":
-        # MQTT plugs report "today" energy, not lifetime total
-        # For per-print tracking, we use "today" as the counter (resets at midnight)
-        mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id)
-        if mqtt_data:
-            return {
-                "power": mqtt_data.power,
-                "today": mqtt_data.energy,
-                "total": mqtt_data.energy,  # Use today as total for per-print calculations
-            }
-        return None
-    elif plug.plug_type == "rest":
-        from backend.app.services.rest_smart_plug import rest_smart_plug_service
-
-        return await rest_smart_plug_service.get_energy(plug)
-    else:
-        return await tasmota_service.get_energy(plug)
-
-
 async def _record_energy_start(archive, printer_id: int, db, *, context: str = "") -> bool:
 async def _record_energy_start(archive, printer_id: int, db, *, context: str = "") -> bool:
     """Capture the smart plug lifetime counter on the archive at print start.
     """Capture the smart plug lifetime counter on the archive at print start.
 
 

+ 3 - 1
backend/app/services/mqtt_smart_plug.py

@@ -26,7 +26,9 @@ class SmartPlugMQTTData:
     power: float | None = None  # Current power in watts
     power: float | None = None  # Current power in watts
     energy: float | None = None  # Energy in kWh (today)
     energy: float | None = None  # Energy in kWh (today)
     state: str | None = None  # "ON" or "OFF"
     state: str | None = None  # "ON" or "OFF"
-    last_seen: datetime = field(default_factory=datetime.utcnow)
+    # Aware, like the value a received message sets: is_reachable subtracts it
+    # from an aware now, which raised TypeError before the first message.
+    last_seen: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
 
 
 
 
 @dataclass
 @dataclass

+ 1 - 1
backend/app/services/notification_service.py

@@ -2093,7 +2093,7 @@ class NotificationService:
 
 
     async def on_printer_error(
     async def on_printer_error(
         self,
         self,
-        printer_id: int,
+        printer_id: int | None,
         printer_name: str,
         printer_name: str,
         error_type: str,
         error_type: str,
         db: AsyncSession,
         db: AsyncSession,

+ 68 - 0
backend/app/services/plug_energy.py

@@ -0,0 +1,68 @@
+"""Reading a smart plug's power and state, whatever kind of plug it is."""
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.smart_plug import SmartPlug
+from backend.app.services.homeassistant import homeassistant_service
+from backend.app.services.mqtt_relay import mqtt_relay
+from backend.app.services.rest_smart_plug import rest_smart_plug_service
+from backend.app.services.tasmota import tasmota_service
+
+
+async def _configured_homeassistant(db: AsyncSession):
+    from backend.app.api.routes.settings import get_homeassistant_settings
+
+    ha_settings = await get_homeassistant_settings(db)
+    homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
+    return homeassistant_service
+
+
+async def get_plug_energy(plug: SmartPlug, db: AsyncSession) -> dict | None:
+    """Get energy from plug regardless of type (Tasmota, Home Assistant, MQTT, or REST).
+
+    For HA plugs, configures the service with current settings from DB.
+    For MQTT plugs, returns data from the subscription service.
+    For REST plugs, polls the status URL with JSON path extraction.
+    """
+    if plug.plug_type == "homeassistant":
+        return await (await _configured_homeassistant(db)).get_energy(plug)
+    elif plug.plug_type == "mqtt":
+        # MQTT plugs report "today" energy, not lifetime total
+        # For per-print tracking, we use "today" as the counter (resets at midnight)
+        mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id)
+        if mqtt_data:
+            return {
+                "power": mqtt_data.power,
+                "today": mqtt_data.energy,
+                "total": mqtt_data.energy,  # Use today as total for per-print calculations
+            }
+        return None
+    elif plug.plug_type == "rest":
+        return await rest_smart_plug_service.get_energy(plug)
+    else:
+        return await tasmota_service.get_energy(plug)
+
+
+async def read_plug(plug: SmartPlug, db: AsyncSession) -> tuple[str | None, float | None]:
+    """The plug's switch state ("ON", "OFF" or None if unknown) and power in watts.
+
+    An unreachable plug reads as (None, None). MQTT plugs answer from the last
+    message received, without contacting the device.
+    """
+    if plug.plug_type == "mqtt":
+        data = mqtt_relay.smart_plug_service.get_plug_data(plug.id)
+        if not data or not mqtt_relay.smart_plug_service.is_reachable(plug.id):
+            return None, None
+        return data.state, data.power
+
+    if plug.plug_type == "homeassistant":
+        service = await _configured_homeassistant(db)
+    elif plug.plug_type == "rest":
+        service = rest_smart_plug_service
+    else:
+        service = tasmota_service
+    status = await service.get_status(plug)
+    if not status.get("reachable"):
+        return None, None
+    energy = await get_plug_energy(plug, db)
+    return status.get("state"), (energy or {}).get("power")

+ 141 - 0
backend/app/services/power_alerts.py

@@ -0,0 +1,141 @@
+"""Smart plug power alerts.
+
+A plug with power alerts on is read in the background (smart_plug_manager), so
+an alert arrives without anyone having Bambuddy open. An alert is sent when the
+power crosses from inside the range to above the high value or below the low
+value, and again only after it has been back inside the range: a printer idling
+below a low threshold is reported once, not every few minutes. The low value is
+only checked while the plug reports itself switched on, so turning a printer
+off raises no "power low" alert; a plug that can't report its switch state
+(a REST plug without a status URL, an MQTT plug with only a power topic) gets
+no low alerts at all.
+"""
+
+import logging
+import time
+from datetime import timedelta
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.smart_plug import SmartPlug
+from backend.app.services.notification_service import notification_service
+from backend.app.services.plug_energy import read_plug
+from backend.app.utils.local_time import to_naive_utc, utcnow_naive
+
+logger = logging.getLogger(__name__)
+
+# Minimum time between two alerts of one plug, so a value hovering at the
+# threshold doesn't send one alert per check.
+COOLDOWN = timedelta(minutes=5)
+
+# How long an unreachable plug is left alone before it is polled again, so a
+# plug that is unplugged doesn't log a connection warning every minute.
+UNREACHABLE_RETRY_SECONDS = 600
+
+
+class PowerAlertMonitor:
+    """Remembers per plug whether the power was last above, below or inside the range.
+
+    Kept in memory: after a restart a plug that is still outside its range
+    alerts once more.
+    """
+
+    def __init__(self) -> None:
+        self._outside: dict[int, str | None] = {}
+        self._retry_at: dict[int, float] = {}
+
+    def forget_all_but(self, plug_ids: set[int]) -> None:
+        """Drop what is known about plugs no longer checked (alerts off, deleted)."""
+        for known in (self._outside, self._retry_at):
+            for plug_id in set(known) - plug_ids:
+                del known[plug_id]
+
+    def is_resting(self, plug_id: int) -> bool:
+        return time.monotonic() < self._retry_at.get(plug_id, 0.0)
+
+    def mark_unreachable(self, plug_id: int) -> None:
+        self._retry_at[plug_id] = time.monotonic() + UNREACHABLE_RETRY_SECONDS
+
+    async def evaluate(self, plug: SmartPlug, state: str | None, power: float | None, db: AsyncSession) -> None:
+        self._retry_at.pop(plug.id, None)
+        if not plug.power_alert_enabled or power is None:
+            return
+        if state == "OFF":
+            self._outside[plug.id] = None
+            return
+
+        if plug.power_alert_high is not None and power > plug.power_alert_high:
+            level, threshold = "high", plug.power_alert_high
+        elif plug.power_alert_low is not None and power < plug.power_alert_low and state == "ON":
+            level, threshold = "low", plug.power_alert_low
+        else:
+            self._outside[plug.id] = None
+            return
+
+        if self._outside.get(plug.id) == level:
+            return
+        last = to_naive_utc(plug.power_alert_last_triggered)
+        if last is not None and utcnow_naive() - last < COOLDOWN:
+            # Not recorded as seen: if the power is still out of range once the
+            # cooldown has passed, the alert goes out then.
+            return
+
+        plug_id, plug_name, printer_id = plug.id, plug.name, plug.printer_id
+        plug.power_alert_last_triggered = utcnow_naive()
+        await db.commit()
+
+        direction = "above" if level == "high" else "below"
+        detail = f"Power consumption is {power:.1f}W, {direction} threshold of {threshold:.1f}W"
+        logger.info("Power alert for plug %s: %s", plug_name, detail)
+        await notification_service.on_printer_error(
+            printer_id=printer_id,
+            printer_name=plug_name,
+            error_type=f"Power {level.title()}",
+            db=db,
+            error_detail=detail,
+        )
+        # Recorded only once it has gone out: a failed write or send is retried
+        # on a later pass (after the cooldown, if the write got through).
+        self._outside[plug_id] = level
+
+
+power_alert_monitor = PowerAlertMonitor()
+
+
+async def check_all_plugs(db: AsyncSession, monitor: PowerAlertMonitor = power_alert_monitor) -> None:
+    """Read every plug with power alerts on and alert on any crossing.
+
+    One plug failing to answer, or failing to save, doesn't stop the others.
+    """
+    plugs = (await db.execute(select(SmartPlug).where(SmartPlug.power_alert_enabled.is_(True)))).scalars().all()
+    checked = [
+        (plug, plug.id, plug.name)
+        for plug in plugs
+        if plug.power_alert_high is not None or plug.power_alert_low is not None
+    ]
+    monitor.forget_all_but({plug_id for _, plug_id, _ in checked})
+
+    rolled_back = False
+    for plug, plug_id, plug_name in checked:
+        if monitor.is_resting(plug_id):
+            continue
+        try:
+            if rolled_back:
+                # A rollback expires every loaded row; reading one lazily on an
+                # async session would fail.
+                await db.refresh(plug)
+            state, power = await read_plug(plug, db)
+            if state is None and power is None:
+                # MQTT plugs are read from memory: nothing to spare by pausing,
+                # and one that hasn't published yet would be ignored for minutes.
+                if plug.plug_type != "mqtt":
+                    monitor.mark_unreachable(plug_id)
+                continue
+            await monitor.evaluate(plug, state, power, db)
+        except Exception as e:
+            # A failed write leaves the session needing a rollback; without it
+            # every plug after this one would fail on the same session.
+            await db.rollback()
+            rolled_back = True
+            logger.warning("Power alert check failed for plug %s: %s", plug_name, e)

+ 27 - 0
backend/app/services/smart_plug_manager.py

@@ -29,6 +29,7 @@ class SmartPlugManager:
         self._loop: asyncio.AbstractEventLoop | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
         self._scheduler_task: asyncio.Task | None = None
         self._scheduler_task: asyncio.Task | None = None
         self._snapshot_task: asyncio.Task | None = None
         self._snapshot_task: asyncio.Task | None = None
+        self._power_alert_task: asyncio.Task | None = None
         self._last_schedule_check: dict[int, str] = {}  # plug_id -> "HH:MM" last executed
         self._last_schedule_check: dict[int, str] = {}  # plug_id -> "HH:MM" last executed
 
 
     async def get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None):
     async def get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None):
@@ -75,6 +76,9 @@ class SmartPlugManager:
         if self._snapshot_task is None:
         if self._snapshot_task is None:
             self._snapshot_task = asyncio.create_task(self._snapshot_loop())
             self._snapshot_task = asyncio.create_task(self._snapshot_loop())
             logger.info("Smart plug energy snapshot loop started")
             logger.info("Smart plug energy snapshot loop started")
+        if self._power_alert_task is None:
+            self._power_alert_task = asyncio.create_task(self._power_alert_loop())
+            logger.info("Smart plug power alert loop started")
 
 
     def stop_scheduler(self):
     def stop_scheduler(self):
         """Stop the background scheduler."""
         """Stop the background scheduler."""
@@ -86,6 +90,10 @@ class SmartPlugManager:
             self._snapshot_task.cancel()
             self._snapshot_task.cancel()
             self._snapshot_task = None
             self._snapshot_task = None
             logger.info("Smart plug energy snapshot loop stopped")
             logger.info("Smart plug energy snapshot loop stopped")
+        if self._power_alert_task:
+            self._power_alert_task.cancel()
+            self._power_alert_task = None
+            logger.info("Smart plug power alert loop stopped")
 
 
     async def _schedule_loop(self):
     async def _schedule_loop(self):
         """Background loop that checks scheduled on/off times every minute."""
         """Background loop that checks scheduled on/off times every minute."""
@@ -98,6 +106,25 @@ class SmartPlugManager:
             # Wait until the next minute
             # Wait until the next minute
             await asyncio.sleep(60)
             await asyncio.sleep(60)
 
 
+    async def _power_alert_loop(self):
+        """Background loop that checks plugs with power alerts on, once a minute.
+
+        Without it an alert would only be noticed while someone had a printer
+        page open, since the plug status is otherwise only read for display.
+        """
+        from backend.app.core import database
+        from backend.app.services.power_alerts import check_all_plugs
+
+        await asyncio.sleep(30)
+        while True:
+            try:
+                # Looked up on each pass: a backup restore replaces the session factory.
+                async with database.async_session() as db:
+                    await check_all_plugs(db)
+            except Exception as e:
+                logger.error("Error in power alert check: %s", e)
+            await asyncio.sleep(60)
+
     async def _snapshot_loop(self):
     async def _snapshot_loop(self):
         """Background loop that captures each plug's lifetime energy counter.
         """Background loop that captures each plug's lifetime energy counter.
 
 

+ 17 - 1
backend/tests/unit/services/test_smart_plug_manager.py

@@ -391,12 +391,20 @@ class TestSmartPlugManager:
         assert manager._scheduler_task is None
         assert manager._scheduler_task is None
 
 
         # Mock _schedule_loop to return a mock coroutine to avoid unawaited coroutine warning
         # Mock _schedule_loop to return a mock coroutine to avoid unawaited coroutine warning
-        with patch.object(manager, "_schedule_loop") as mock_loop, patch("asyncio.create_task") as mock_create:
+        with (
+            patch.object(manager, "_schedule_loop") as mock_loop,
+            patch.object(manager, "_snapshot_loop"),
+            patch.object(manager, "_power_alert_loop") as mock_power_alert,
+            patch("asyncio.create_task") as mock_create,
+        ):
             mock_create.return_value = MagicMock()
             mock_create.return_value = MagicMock()
             manager.start_scheduler()
             manager.start_scheduler()
 
 
             assert manager._scheduler_task is not None
             assert manager._scheduler_task is not None
             mock_loop.assert_called_once()
             mock_loop.assert_called_once()
+            # Power alerts are checked in the background, not only while a page is open.
+            assert manager._power_alert_task is not None
+            mock_power_alert.assert_called_once()
 
 
     def test_stop_scheduler(self, manager):
     def test_stop_scheduler(self, manager):
         """Verify scheduler can be stopped."""
         """Verify scheduler can be stopped."""
@@ -414,11 +422,13 @@ class TestSmartPlugManager:
         mock_snapshot_task = MagicMock()
         mock_snapshot_task = MagicMock()
         manager._scheduler_task = mock_schedule_task
         manager._scheduler_task = mock_schedule_task
         manager._snapshot_task = mock_snapshot_task
         manager._snapshot_task = mock_snapshot_task
+        manager._power_alert_task = MagicMock()
 
 
         # Mock the loop coroutines to avoid unawaited coroutine warnings
         # Mock the loop coroutines to avoid unawaited coroutine warnings
         with (
         with (
             patch.object(manager, "_schedule_loop") as mock_loop,
             patch.object(manager, "_schedule_loop") as mock_loop,
             patch.object(manager, "_snapshot_loop") as mock_snapshot,
             patch.object(manager, "_snapshot_loop") as mock_snapshot,
+            patch.object(manager, "_power_alert_loop") as mock_power_alert,
             patch("asyncio.create_task") as mock_create,
             patch("asyncio.create_task") as mock_create,
         ):
         ):
             manager.start_scheduler()
             manager.start_scheduler()
@@ -426,6 +436,7 @@ class TestSmartPlugManager:
             mock_create.assert_not_called()  # Should not create new tasks
             mock_create.assert_not_called()  # Should not create new tasks
             mock_loop.assert_not_called()
             mock_loop.assert_not_called()
             mock_snapshot.assert_not_called()
             mock_snapshot.assert_not_called()
+            mock_power_alert.assert_not_called()
 
 
     def test_stop_scheduler_cancels_snapshot_task(self, manager):
     def test_stop_scheduler_cancels_snapshot_task(self, manager):
         """Verify stopping scheduler also cancels the snapshot loop (#941)."""
         """Verify stopping scheduler also cancels the snapshot loop (#941)."""
@@ -434,10 +445,15 @@ class TestSmartPlugManager:
         manager._scheduler_task = mock_schedule_task
         manager._scheduler_task = mock_schedule_task
         manager._snapshot_task = mock_snapshot_task
         manager._snapshot_task = mock_snapshot_task
 
 
+        mock_power_alert_task = MagicMock()
+        manager._power_alert_task = mock_power_alert_task
+
         manager.stop_scheduler()
         manager.stop_scheduler()
 
 
         mock_schedule_task.cancel.assert_called_once()
         mock_schedule_task.cancel.assert_called_once()
         mock_snapshot_task.cancel.assert_called_once()
         mock_snapshot_task.cancel.assert_called_once()
+        mock_power_alert_task.cancel.assert_called_once()
+        assert manager._power_alert_task is None
         assert manager._scheduler_task is None
         assert manager._scheduler_task is None
         assert manager._snapshot_task is None
         assert manager._snapshot_task is None
 
 

+ 369 - 0
backend/tests/unit/test_power_alerts.py

@@ -0,0 +1,369 @@
+"""Power alerts: checked in the background, sent once per crossing.
+
+Pre-fix the check called a ``notification_service.send_notification`` that does
+not exist, so no power alert was ever sent and the plug status request failed
+with 500 once a threshold was crossed. It also only ran from that status
+request, i.e. while someone had the printer page open, and it fired every five
+minutes for as long as the power stayed outside the range.
+"""
+
+from datetime import timedelta
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.smart_plug import SmartPlug
+from backend.app.services.power_alerts import PowerAlertMonitor, check_all_plugs
+from backend.app.utils.local_time import utcnow_naive
+
+
+async def _plug(db_session, **kwargs) -> SmartPlug:
+    fields = {
+        "name": "Printer plug",
+        "ip_address": "192.168.1.50",
+        "power_alert_enabled": True,
+        "power_alert_high": 200.0,
+        "power_alert_low": None,
+    }
+    fields.update(kwargs)
+    plug = SmartPlug(**fields)
+    db_session.add(plug)
+    await db_session.commit()
+    return plug
+
+
+@pytest.fixture
+def sent():
+    with patch(
+        "backend.app.services.power_alerts.notification_service.on_printer_error", new_callable=AsyncMock
+    ) as on_printer_error:
+        yield on_printer_error
+
+
+class TestCrossings:
+    @pytest.mark.asyncio
+    async def test_alerts_once_when_power_rises_above_the_high_value(self, db_session, sent):
+        plug = await _plug(db_session)
+        monitor = PowerAlertMonitor()
+
+        for watts in (150.0, 250.0, 260.0, 255.0):
+            await monitor.evaluate(plug, "ON", watts, db_session)
+
+        sent.assert_awaited_once()
+        kwargs = sent.await_args.kwargs
+        assert kwargs["printer_name"] == "Printer plug"
+        assert kwargs["error_type"] == "Power High"
+        assert "250.0W" in kwargs["error_detail"] and "200.0W" in kwargs["error_detail"]
+        assert plug.power_alert_last_triggered is not None
+
+    @pytest.mark.asyncio
+    async def test_alerts_again_after_the_power_was_back_in_range(self, db_session, sent):
+        plug = await _plug(db_session)
+        monitor = PowerAlertMonitor()
+
+        await monitor.evaluate(plug, "ON", 250.0, db_session)
+        await monitor.evaluate(plug, "ON", 150.0, db_session)
+        plug.power_alert_last_triggered = utcnow_naive() - timedelta(minutes=10)
+        await monitor.evaluate(plug, "ON", 250.0, db_session)
+
+        assert sent.await_count == 2
+
+    @pytest.mark.asyncio
+    async def test_alerts_when_power_falls_below_the_low_value(self, db_session, sent):
+        plug = await _plug(db_session, power_alert_high=None, power_alert_low=20.0)
+        monitor = PowerAlertMonitor()
+
+        await monitor.evaluate(plug, "ON", 80.0, db_session)
+        await monitor.evaluate(plug, "ON", 5.0, db_session)
+
+        sent.assert_awaited_once()
+        assert sent.await_args.kwargs["error_type"] == "Power Low"
+
+    @pytest.mark.asyncio
+    async def test_a_switched_off_plug_raises_no_low_alert(self, db_session, sent):
+        plug = await _plug(db_session, power_alert_high=None, power_alert_low=20.0)
+        monitor = PowerAlertMonitor()
+
+        await monitor.evaluate(plug, "ON", 80.0, db_session)
+        await monitor.evaluate(plug, "OFF", 0.0, db_session)
+
+        sent.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_a_crossing_inside_the_cooldown_is_sent_once_it_ends(self, db_session, sent):
+        plug = await _plug(db_session, power_alert_last_triggered=utcnow_naive())
+        monitor = PowerAlertMonitor()
+
+        await monitor.evaluate(plug, "ON", 250.0, db_session)
+        sent.assert_not_awaited()
+
+        plug.power_alert_last_triggered = utcnow_naive() - timedelta(minutes=10)
+        await monitor.evaluate(plug, "ON", 250.0, db_session)
+        sent.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_disabled_alerts_send_nothing(self, db_session, sent):
+        plug = await _plug(db_session, power_alert_enabled=False)
+
+        await PowerAlertMonitor().evaluate(plug, "ON", 999.0, db_session)
+
+        sent.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_the_linked_printer_is_passed_for_the_provider_filter(self, db_session, sent, printer_factory):
+        printer = await printer_factory()
+        plug = await _plug(db_session, printer_id=printer.id)
+
+        await PowerAlertMonitor().evaluate(plug, "ON", 250.0, db_session)
+
+        assert sent.await_args.kwargs["printer_id"] == printer.id
+
+
+class TestBackgroundPass:
+    @pytest.mark.asyncio
+    async def test_reads_only_plugs_with_alerts_on_and_survives_an_unreachable_one(self, db_session, sent):
+        down = await _plug(db_session, name="Down")
+        up = await _plug(db_session, name="Up", ip_address="192.168.1.51")
+        await _plug(db_session, name="Off", ip_address="192.168.1.52", power_alert_enabled=False)
+        down_id, up_id = down.id, up.id
+
+        read_names = []
+
+        async def read(plug, db):
+            read_names.append(plug.name)
+            if plug.id == down_id:
+                raise TimeoutError("unreachable")
+            return "ON", 250.0
+
+        with patch("backend.app.services.power_alerts.read_plug", side_effect=read):
+            await check_all_plugs(db_session, PowerAlertMonitor())
+
+        assert sorted(read_names) == ["Down", "Up"]
+        sent.assert_awaited_once()
+        assert sent.await_args.kwargs["printer_name"] == "Up"
+        refreshed = (await db_session.execute(select(SmartPlug).where(SmartPlug.id == up_id))).scalar_one()
+        assert refreshed.power_alert_last_triggered is not None
+
+
+class TestStatusRouteNoLongerChecks:
+    @pytest.mark.asyncio
+    async def test_status_answers_with_a_plug_above_its_threshold(self, async_client, db_session, sent):
+        plug = await _plug(db_session)
+        with (
+            patch(
+                "backend.app.api.routes.smart_plugs.tasmota_service.get_status",
+                AsyncMock(return_value={"state": "ON", "reachable": True, "device_name": None}),
+            ),
+            patch(
+                "backend.app.api.routes.smart_plugs.tasmota_service.get_energy",
+                AsyncMock(return_value={"power": 250.0, "today": 1.0, "total": 5.0}),
+            ),
+        ):
+            response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}/status")
+
+        assert response.status_code == 200, response.text
+        assert response.json()["energy"]["power"] == 250.0
+        sent.assert_not_awaited()
+
+
+class TestReadPlug:
+    """State and power for every plug type; an unreachable plug reads as nothing."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("plug_type", ["tasmota", "rest", "homeassistant"])
+    async def test_polled_plugs(self, db_session, plug_type):
+        from backend.app.services import plug_energy
+
+        plug = await _plug(db_session, plug_type=plug_type)
+        service = {
+            "tasmota": plug_energy.tasmota_service,
+            "rest": plug_energy.rest_smart_plug_service,
+            "homeassistant": plug_energy.homeassistant_service,
+        }[plug_type]
+        with (
+            patch.object(service, "get_status", AsyncMock(return_value={"state": "ON", "reachable": True})),
+            patch.object(service, "get_energy", AsyncMock(return_value={"power": 42.5})),
+            patch(
+                "backend.app.api.routes.settings.get_homeassistant_settings",
+                AsyncMock(return_value={"ha_url": "http://ha", "ha_token": "t"}),
+            ),
+        ):
+            assert await plug_energy.read_plug(plug, db_session) == ("ON", 42.5)
+
+        with (
+            patch.object(service, "get_status", AsyncMock(return_value={"state": None, "reachable": False})),
+            patch(
+                "backend.app.api.routes.settings.get_homeassistant_settings",
+                AsyncMock(return_value={"ha_url": "http://ha", "ha_token": "t"}),
+            ),
+        ):
+            assert await plug_energy.read_plug(plug, db_session) == (None, None)
+
+    @pytest.mark.asyncio
+    async def test_mqtt_plug_answers_from_its_last_message(self, db_session):
+        from types import SimpleNamespace
+
+        from backend.app.services import plug_energy
+
+        plug = await _plug(db_session, plug_type="mqtt")
+        mqtt = plug_energy.mqtt_relay.smart_plug_service
+        with (
+            patch.object(mqtt, "get_plug_data", return_value=SimpleNamespace(state="OFF", power=0.0, energy=1.0)),
+            patch.object(mqtt, "is_reachable", return_value=True),
+        ):
+            assert await plug_energy.read_plug(plug, db_session) == ("OFF", 0.0)
+        with patch.object(mqtt, "get_plug_data", return_value=None):
+            assert await plug_energy.read_plug(plug, db_session) == (None, None)
+
+
+class TestReviewRound1:
+    """Alerts must not be lost on a failed write, one plug's error must not stop
+    the others, stale state must not swallow a crossing, a plug that can't say
+    whether it is on raises no low alert, and an unreachable plug is not polled
+    every minute."""
+
+    @pytest.mark.asyncio
+    async def test_a_failed_write_does_not_lose_the_alert(self, db_session, sent):
+        from sqlalchemy.exc import OperationalError
+
+        plug = await _plug(db_session)
+        monitor = PowerAlertMonitor()
+        real_commit = db_session.commit
+        failing = AsyncMock(side_effect=OperationalError("UPDATE", {}, Exception("database is locked")))
+
+        with (
+            patch("backend.app.services.power_alerts.read_plug", AsyncMock(return_value=("ON", 250.0))),
+            patch.object(db_session, "commit", failing),
+        ):
+            await check_all_plugs(db_session, monitor)
+        sent.assert_not_awaited()
+
+        db_session.commit = real_commit
+        with patch("backend.app.services.power_alerts.read_plug", AsyncMock(return_value=("ON", 250.0))):
+            await check_all_plugs(db_session, monitor)
+        sent.assert_awaited_once()
+        assert sent.await_args.kwargs["printer_name"] == plug.name
+
+    @pytest.mark.asyncio
+    async def test_a_failed_send_is_retried_after_the_cooldown(self, db_session, sent):
+        plug = await _plug(db_session)
+        plug_id = plug.id
+        monitor = PowerAlertMonitor()
+        sent.side_effect = [RuntimeError("provider down"), None]
+
+        with patch("backend.app.services.power_alerts.read_plug", AsyncMock(return_value=("ON", 250.0))):
+            await check_all_plugs(db_session, monitor)
+            row = (await db_session.execute(select(SmartPlug).where(SmartPlug.id == plug_id))).scalar_one()
+            row.power_alert_last_triggered = utcnow_naive() - timedelta(minutes=10)
+            await db_session.commit()
+            await check_all_plugs(db_session, monitor)
+
+        assert sent.await_count == 2
+
+    @pytest.mark.asyncio
+    async def test_one_plugs_db_error_does_not_stop_the_others(self, test_engine, db_session, sent):
+        """A real failed UPDATE leaves the session needing a rollback, as
+        "database is locked" does on SQLite."""
+        from sqlalchemy import event
+        from sqlalchemy.exc import OperationalError
+
+        await _plug(db_session, name="First")
+        await _plug(db_session, name="Second", ip_address="192.168.1.51")
+        failed = {"done": False}
+
+        def fail_first_update(conn, cursor, statement, parameters, context, executemany):
+            if statement.lstrip().upper().startswith("UPDATE SMART_PLUGS") and not failed["done"]:
+                failed["done"] = True
+                raise OperationalError(statement, parameters, Exception("database is locked"))
+
+        event.listen(test_engine.sync_engine, "before_cursor_execute", fail_first_update)
+        try:
+            with patch("backend.app.services.power_alerts.read_plug", AsyncMock(return_value=("ON", 250.0))):
+                await check_all_plugs(db_session, PowerAlertMonitor())
+        finally:
+            event.remove(test_engine.sync_engine, "before_cursor_execute", fail_first_update)
+
+        assert failed["done"]
+        assert [c.kwargs["printer_name"] for c in sent.await_args_list] == ["Second"]
+
+    @pytest.mark.asyncio
+    async def test_switching_alerts_off_and_on_forgets_the_old_crossing(self, db_session, sent):
+        plug = await _plug(db_session, power_alert_high=None, power_alert_low=10.0)
+        plug_id = plug.id
+        monitor = PowerAlertMonitor()
+        reader = AsyncMock(return_value=("ON", 3.0))
+
+        async def set_enabled(enabled: bool):
+            row = (await db_session.execute(select(SmartPlug).where(SmartPlug.id == plug_id))).scalar_one()
+            row.power_alert_enabled = enabled
+            row.power_alert_last_triggered = utcnow_naive() - timedelta(minutes=10)
+            await db_session.commit()
+
+        with patch("backend.app.services.power_alerts.read_plug", reader):
+            await check_all_plugs(db_session, monitor)
+            await set_enabled(False)
+            await check_all_plugs(db_session, monitor)
+            await set_enabled(True)
+            await check_all_plugs(db_session, monitor)
+
+        assert sent.await_count == 2
+
+    @pytest.mark.asyncio
+    async def test_unknown_switch_state_raises_no_low_alert_but_still_high(self, db_session, sent):
+        plug = await _plug(db_session, power_alert_high=200.0, power_alert_low=10.0)
+        monitor = PowerAlertMonitor()
+
+        await monitor.evaluate(plug, None, 0.0, db_session)
+        sent.assert_not_awaited()
+
+        await monitor.evaluate(plug, None, 250.0, db_session)
+        sent.assert_awaited_once()
+        assert sent.await_args.kwargs["error_type"] == "Power High"
+
+    @pytest.mark.asyncio
+    async def test_an_unreachable_plug_is_polled_again_only_after_a_pause(self, db_session, sent):
+        await _plug(db_session)
+        monitor = PowerAlertMonitor()
+        reader = AsyncMock(return_value=(None, None))
+        clock = {"now": 1000.0}
+
+        with (
+            patch("backend.app.services.power_alerts.read_plug", reader),
+            patch("backend.app.services.power_alerts.time.monotonic", side_effect=lambda: clock["now"]),
+        ):
+            await check_all_plugs(db_session, monitor)
+            clock["now"] += 60
+            await check_all_plugs(db_session, monitor)
+            assert reader.await_count == 1
+
+            clock["now"] += 600
+            await check_all_plugs(db_session, monitor)
+            assert reader.await_count == 2
+
+
+class TestReviewRound2:
+    def test_a_fresh_mqtt_subscription_can_be_asked_if_reachable(self):
+        """Its default last_seen had no time zone, so the check raised TypeError
+        until the first message arrived."""
+        from backend.app.services.mqtt_smart_plug import MQTTSmartPlugService, SmartPlugMQTTData
+
+        service = MQTTSmartPlugService()
+        service.plug_data[7] = SmartPlugMQTTData(plug_id=7)
+
+        assert service.is_reachable(7) is True
+
+    @pytest.mark.asyncio
+    async def test_an_mqtt_plug_without_data_is_not_paused(self, db_session, sent):
+        """MQTT is read from memory, so there is no network call to spare; a plug
+        that hasn't published yet must be checked again on the next pass."""
+        await _plug(db_session, plug_type="mqtt")
+        monitor = PowerAlertMonitor()
+        reader = AsyncMock(side_effect=[(None, None), ("ON", 250.0)])
+
+        with patch("backend.app.services.power_alerts.read_plug", reader):
+            await check_all_plugs(db_session, monitor)
+            await check_all_plugs(db_session, monitor)
+
+        assert reader.await_count == 2
+        sent.assert_awaited_once()