ソースを参照

Improved auto power off scheduler

maziggy 5 ヶ月 前
コミット
c3026632da

+ 28 - 0
backend/app/core/database.py

@@ -257,6 +257,34 @@ async def run_migrations(conn):
     except Exception:
         pass
 
+    # Migration: Add auto_off_pending columns to smart_plugs (for restart recovery)
+    try:
+        await conn.execute(text(
+            "ALTER TABLE smart_plugs ADD COLUMN auto_off_pending BOOLEAN DEFAULT 0"
+        ))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text(
+            "ALTER TABLE smart_plugs ADD COLUMN auto_off_pending_since DATETIME"
+        ))
+    except Exception:
+        pass
+
+    # Migration: Add AMS alarm notification columns to notification_providers
+    try:
+        await conn.execute(text(
+            "ALTER TABLE notification_providers ADD COLUMN on_ams_humidity_high BOOLEAN DEFAULT 0"
+        ))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text(
+            "ALTER TABLE notification_providers ADD COLUMN on_ams_temperature_high BOOLEAN DEFAULT 0"
+        ))
+    except Exception:
+        pass
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 3 - 0
backend/app/main.py

@@ -1178,6 +1178,9 @@ async def lifespan(app: FastAPI):
     # Start the smart plug scheduler for time-based on/off
     smart_plug_manager.start_scheduler()
 
+    # Resume any pending auto-offs that were interrupted by restart
+    await smart_plug_manager.resume_pending_auto_offs()
+
     # Start the notification digest scheduler
     notification_service.start_digest_scheduler()
 

+ 2 - 0
backend/app/models/smart_plug.py

@@ -48,6 +48,8 @@ class SmartPlug(Base):
     last_state: Mapped[str | None] = mapped_column(String(10), nullable=True)  # "ON"/"OFF"
     last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     auto_off_executed: Mapped[bool] = mapped_column(Boolean, default=False)  # True when auto-off was triggered
+    auto_off_pending: Mapped[bool] = mapped_column(Boolean, default=False)  # True when waiting for cooldown
+    auto_off_pending_since: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)  # When auto-off was scheduled
 
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

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

@@ -187,6 +187,9 @@ class SmartPlugManager:
             f"Scheduling turn-off for plug '{plug.name}' in {delay_seconds} seconds"
         )
 
+        # Mark as pending in database (survives restarts)
+        asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
+
         task = asyncio.create_task(
             self._delayed_off(plug.id, plug.ip_address, plug.username, plug.password, printer_id, delay_seconds)
         )
@@ -240,6 +243,9 @@ class SmartPlugManager:
             f"(threshold: {temp_threshold}°C)"
         )
 
+        # Mark as pending in database (survives restarts)
+        asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
+
         task = asyncio.create_task(
             self._temp_based_off(
                 plug.id,
@@ -331,6 +337,25 @@ class SmartPlugManager:
         finally:
             self._pending_off.pop(plug_id, None)
 
+    async def _mark_auto_off_pending(self, plug_id: int, pending: bool):
+        """Mark a plug as having a pending auto-off (survives restarts)."""
+        try:
+            from backend.app.core.database import async_session
+            from backend.app.models.smart_plug import SmartPlug
+
+            async with async_session() as db:
+                result = await db.execute(
+                    select(SmartPlug).where(SmartPlug.id == plug_id)
+                )
+                plug = result.scalar_one_or_none()
+                if plug:
+                    plug.auto_off_pending = pending
+                    plug.auto_off_pending_since = datetime.utcnow() if pending else None
+                    await db.commit()
+                    logger.debug(f"Marked plug {plug_id} auto_off_pending={pending}")
+        except Exception as e:
+            logger.warning(f"Failed to update plug {plug_id} pending state: {e}")
+
     async def _mark_auto_off_executed(self, plug_id: int):
         """Disable auto-off after it was executed (one-shot behavior)."""
         try:
@@ -345,6 +370,8 @@ class SmartPlugManager:
                 if plug:
                     plug.auto_off = False  # Disable auto-off (one-shot behavior)
                     plug.auto_off_executed = False  # Reset the flag
+                    plug.auto_off_pending = False  # Clear pending state
+                    plug.auto_off_pending_since = None
                     plug.last_state = "OFF"
                     plug.last_checked = datetime.utcnow()
                     await db.commit()
@@ -358,12 +385,78 @@ class SmartPlugManager:
             logger.debug(f"Cancelling pending turn-off for plug {plug_id}")
             self._pending_off[plug_id].cancel()
             del self._pending_off[plug_id]
+            # Clear pending state in database
+            asyncio.create_task(self._mark_auto_off_pending(plug_id, False))
 
     def cancel_all_pending(self):
         """Cancel all pending turn-off tasks."""
         for plug_id in list(self._pending_off.keys()):
             self._cancel_pending_off(plug_id)
 
+    async def resume_pending_auto_offs(self):
+        """Resume any pending auto-offs that were interrupted by a restart.
+
+        Called on startup to check for plugs that had auto-off pending but
+        never completed (e.g., due to service restart).
+        """
+        try:
+            from backend.app.core.database import async_session
+            from backend.app.models.smart_plug import SmartPlug
+
+            async with async_session() as db:
+                # Find all plugs with pending auto-off
+                result = await db.execute(
+                    select(SmartPlug).where(
+                        SmartPlug.auto_off_pending == True,
+                        SmartPlug.printer_id != None,
+                    )
+                )
+                pending_plugs = result.scalars().all()
+
+                for plug in pending_plugs:
+                    # Check how long it's been pending (timeout after 2 hours)
+                    if plug.auto_off_pending_since:
+                        elapsed = (datetime.utcnow() - plug.auto_off_pending_since).total_seconds()
+                        if elapsed > 7200:  # 2 hours
+                            logger.warning(
+                                f"Auto-off for plug '{plug.name}' was pending for {elapsed/60:.0f} minutes, "
+                                f"clearing stale pending state"
+                            )
+                            plug.auto_off_pending = False
+                            plug.auto_off_pending_since = None
+                            await db.commit()
+                            continue
+
+                    logger.info(
+                        f"Resuming pending auto-off for plug '{plug.name}' "
+                        f"(printer {plug.printer_id})"
+                    )
+
+                    # Resume the appropriate off mode
+                    if plug.off_delay_mode == "temperature":
+                        self._schedule_temp_based_off(plug, plug.printer_id, plug.off_temp_threshold)
+                    else:
+                        # For time mode, just turn off immediately since delay already passed
+                        logger.info(f"Time-based auto-off was pending, turning off plug '{plug.name}' now")
+
+                        class PlugInfo:
+                            def __init__(self, p):
+                                self.ip_address = p.ip_address
+                                self.username = p.username
+                                self.password = p.password
+                                self.name = p.name
+
+                        success = await tasmota_service.turn_off(PlugInfo(plug))
+                        if success:
+                            await self._mark_auto_off_executed(plug.id)
+                            printer_manager.mark_printer_offline(plug.printer_id)
+
+                if pending_plugs:
+                    logger.info(f"Resumed {len(pending_plugs)} pending auto-off(s)")
+
+        except Exception as e:
+            logger.warning(f"Failed to resume pending auto-offs: {e}")
+
 
 # Global singleton
 smart_plug_manager = SmartPlugManager()