diff --git a/backend/app/core/database.py b/backend/app/core/database.py index f8c85d347..171821c3a 100644 --- a/backend/app/core/database.py +++ b/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.""" diff --git a/backend/app/main.py b/backend/app/main.py index 6c714544b..28d090586 100644 --- a/backend/app/main.py +++ b/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() diff --git a/backend/app/models/smart_plug.py b/backend/app/models/smart_plug.py index bbff9ba68..04a78401d 100644 --- a/backend/app/models/smart_plug.py +++ b/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()) diff --git a/backend/app/services/smart_plug_manager.py b/backend/app/services/smart_plug_manager.py index 17b33b77d..510c81ced 100644 --- a/backend/app/services/smart_plug_manager.py +++ b/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()