Improved auto power off scheduler

This commit is contained in:
maziggy
2025-12-11 08:02:15 +01:00
parent e392597192
commit c3026632da
4 changed files with 126 additions and 0 deletions
+28
View File
@@ -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
View File
@@ -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
View File
@@ -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())
@@ -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()