mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Improved auto power off scheduler
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user