mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
Add persistent auto-off option for smart plugs (#826)
Auto-off now has a "Keep Enabled" toggle that keeps it active between prints instead of disabling after each use (one-shot). Useful for HA accessories like BentoBox filters that should always power off after prints. Default behavior (one-shot) is unchanged.
This commit is contained in:
@@ -346,6 +346,12 @@ async def run_migrations(conn):
|
||||
except OperationalError:
|
||||
pass # Already applied
|
||||
|
||||
# Migration: Add auto_off_persistent column to smart_plugs (keep auto-off enabled between prints)
|
||||
try:
|
||||
await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_persistent BOOLEAN DEFAULT 0"))
|
||||
except OperationalError:
|
||||
pass # Already applied
|
||||
|
||||
# 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"))
|
||||
@@ -597,6 +603,7 @@ async def run_migrations(conn):
|
||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
auto_on BOOLEAN NOT NULL DEFAULT 1,
|
||||
auto_off BOOLEAN NOT NULL DEFAULT 1,
|
||||
auto_off_persistent BOOLEAN NOT NULL DEFAULT 0,
|
||||
off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',
|
||||
off_delay_minutes INTEGER NOT NULL DEFAULT 5,
|
||||
off_temp_threshold INTEGER NOT NULL DEFAULT 70,
|
||||
@@ -625,7 +632,8 @@ async def run_migrations(conn):
|
||||
INSERT INTO smart_plugs_new
|
||||
SELECT id, name, ip_address,
|
||||
COALESCE(plug_type, 'tasmota'), ha_entity_id, printer_id,
|
||||
enabled, auto_on, auto_off, off_delay_mode, off_delay_minutes, off_temp_threshold,
|
||||
enabled, auto_on, auto_off, COALESCE(auto_off_persistent, 0),
|
||||
off_delay_mode, off_delay_minutes, off_temp_threshold,
|
||||
username, password, power_alert_enabled, power_alert_high, power_alert_low,
|
||||
power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,
|
||||
COALESCE(show_in_switchbar, 0), last_state, last_checked, auto_off_executed,
|
||||
@@ -894,6 +902,7 @@ async def run_migrations(conn):
|
||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
auto_on BOOLEAN NOT NULL DEFAULT 1,
|
||||
auto_off BOOLEAN NOT NULL DEFAULT 1,
|
||||
auto_off_persistent BOOLEAN NOT NULL DEFAULT 0,
|
||||
off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',
|
||||
off_delay_minutes INTEGER NOT NULL DEFAULT 5,
|
||||
off_temp_threshold INTEGER NOT NULL DEFAULT 70,
|
||||
@@ -923,7 +932,8 @@ async def run_migrations(conn):
|
||||
INSERT INTO smart_plugs_temp
|
||||
SELECT id, name, ip_address, plug_type, ha_entity_id, ha_power_entity,
|
||||
ha_energy_today_entity, ha_energy_total_entity, printer_id, enabled,
|
||||
auto_on, auto_off, off_delay_mode, off_delay_minutes, off_temp_threshold,
|
||||
auto_on, auto_off, COALESCE(auto_off_persistent, 0),
|
||||
off_delay_mode, off_delay_minutes, off_temp_threshold,
|
||||
username, password, power_alert_enabled, power_alert_high, power_alert_low,
|
||||
power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,
|
||||
show_in_switchbar, last_state, last_checked, auto_off_executed,
|
||||
|
||||
@@ -57,6 +57,7 @@ class SmartPlug(Base):
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
auto_on: Mapped[bool] = mapped_column(Boolean, default=True) # Turn on at print start
|
||||
auto_off: Mapped[bool] = mapped_column(Boolean, default=True) # Turn off at print complete/fail
|
||||
auto_off_persistent: Mapped[bool] = mapped_column(Boolean, default=False) # Keep auto-off enabled between prints
|
||||
|
||||
# Turn-off delay mode: "time" or "temperature"
|
||||
off_delay_mode: Mapped[str] = mapped_column(String(20), default="time")
|
||||
|
||||
@@ -48,6 +48,7 @@ class SmartPlugBase(BaseModel):
|
||||
enabled: bool = True
|
||||
auto_on: bool = True
|
||||
auto_off: bool = True
|
||||
auto_off_persistent: bool = False
|
||||
off_delay_mode: Literal["time", "temperature"] = "time"
|
||||
off_delay_minutes: int = Field(default=5, ge=0, le=60)
|
||||
off_temp_threshold: int = Field(default=70, ge=30, le=150)
|
||||
@@ -115,6 +116,7 @@ class SmartPlugUpdate(BaseModel):
|
||||
enabled: bool | None = None
|
||||
auto_on: bool | None = None
|
||||
auto_off: bool | None = None
|
||||
auto_off_persistent: bool | None = None
|
||||
off_delay_mode: Literal["time", "temperature"] | None = None
|
||||
off_delay_minutes: int | None = Field(default=None, ge=0, le=60)
|
||||
off_temp_threshold: int | None = Field(default=None, ge=30, le=150)
|
||||
|
||||
@@ -416,7 +416,7 @@ class SmartPlugManager:
|
||||
logger.warning("Failed to update plug %s pending state: %s", plug_id, e)
|
||||
|
||||
async def _mark_auto_off_executed(self, plug_id: int):
|
||||
"""Disable auto-off after it was executed (one-shot behavior)."""
|
||||
"""Disable auto-off after it was executed (one-shot behavior unless persistent)."""
|
||||
try:
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
@@ -425,14 +425,18 @@ class SmartPlugManager:
|
||||
result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
|
||||
plug = result.scalar_one_or_none()
|
||||
if plug:
|
||||
plug.auto_off = False # Disable auto-off (one-shot behavior)
|
||||
if not plug.auto_off_persistent:
|
||||
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.now(timezone.utc)
|
||||
await db.commit()
|
||||
logger.info("Auto-off executed and disabled for plug %s", plug_id)
|
||||
if plug.auto_off_persistent:
|
||||
logger.info("Auto-off executed for plug %s (persistent, stays enabled)", plug_id)
|
||||
else:
|
||||
logger.info("Auto-off executed and disabled for plug %s", plug_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to update plug %s after auto-off: %s", plug_id, e)
|
||||
|
||||
|
||||
@@ -417,6 +417,149 @@ class TestGetPlugForPrinter:
|
||||
assert result is script1
|
||||
|
||||
|
||||
class TestAutoOffPersistent:
|
||||
"""Tests for persistent auto-off behavior (Issue #826).
|
||||
|
||||
When auto_off_persistent is True, auto_off should remain enabled after
|
||||
execution instead of being disabled (one-shot default).
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def manager(self):
|
||||
return SmartPlugManager()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_auto_off_executed_one_shot_disables_auto_off(self, manager):
|
||||
"""Default one-shot: auto_off should be set to False after execution."""
|
||||
mock_plug = MagicMock()
|
||||
mock_plug.id = 1
|
||||
mock_plug.auto_off = True
|
||||
mock_plug.auto_off_persistent = False
|
||||
mock_plug.auto_off_executed = False
|
||||
mock_plug.auto_off_pending = True
|
||||
mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
|
||||
|
||||
with patch("backend.app.core.database.async_session") as mock_session_ctx:
|
||||
mock_db = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = mock_plug
|
||||
mock_db.execute = AsyncMock(return_value=mock_result)
|
||||
mock_db.commit = AsyncMock()
|
||||
|
||||
mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
|
||||
mock_session_ctx.return_value.__aexit__ = AsyncMock()
|
||||
|
||||
await manager._mark_auto_off_executed(1)
|
||||
|
||||
assert mock_plug.auto_off is False, "One-shot: auto_off should be disabled"
|
||||
assert mock_plug.auto_off_pending is False
|
||||
assert mock_plug.auto_off_pending_since is None
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_auto_off_executed_persistent_keeps_auto_off_enabled(self, manager):
|
||||
"""Persistent mode: auto_off should remain True after execution."""
|
||||
mock_plug = MagicMock()
|
||||
mock_plug.id = 2
|
||||
mock_plug.auto_off = True
|
||||
mock_plug.auto_off_persistent = True
|
||||
mock_plug.auto_off_executed = False
|
||||
mock_plug.auto_off_pending = True
|
||||
mock_plug.auto_off_pending_since = datetime.now(timezone.utc)
|
||||
|
||||
with patch("backend.app.core.database.async_session") as mock_session_ctx:
|
||||
mock_db = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = mock_plug
|
||||
mock_db.execute = AsyncMock(return_value=mock_result)
|
||||
mock_db.commit = AsyncMock()
|
||||
|
||||
mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
|
||||
mock_session_ctx.return_value.__aexit__ = AsyncMock()
|
||||
|
||||
await manager._mark_auto_off_executed(2)
|
||||
|
||||
assert mock_plug.auto_off is True, "Persistent: auto_off should stay enabled"
|
||||
assert mock_plug.auto_off_pending is False
|
||||
assert mock_plug.auto_off_pending_since is None
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persistent_auto_off_full_cycle(self, manager):
|
||||
"""Verify persistent auto-off survives a full print cycle.
|
||||
|
||||
Simulates: print start → print complete → auto-off executes → next print start.
|
||||
auto_off should remain True throughout for persistent plugs.
|
||||
"""
|
||||
mock_plug = MagicMock()
|
||||
mock_plug.id = 3
|
||||
mock_plug.name = "HA BentoBox Filter"
|
||||
mock_plug.plug_type = "homeassistant"
|
||||
mock_plug.ha_entity_id = "switch.bentobox_filter"
|
||||
mock_plug.ip_address = None
|
||||
mock_plug.username = None
|
||||
mock_plug.password = None
|
||||
mock_plug.enabled = True
|
||||
mock_plug.auto_on = True
|
||||
mock_plug.auto_off = True
|
||||
mock_plug.auto_off_persistent = True
|
||||
mock_plug.off_delay_mode = "time"
|
||||
mock_plug.off_delay_minutes = 1
|
||||
mock_plug.off_temp_threshold = 70
|
||||
mock_plug.printer_id = 1
|
||||
mock_plug.auto_off_executed = False
|
||||
mock_plug.auto_off_pending = False
|
||||
mock_plug.last_state = "OFF"
|
||||
mock_plug.last_checked = None
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_db.commit = AsyncMock()
|
||||
|
||||
# Step 1: Print starts — plug turns on
|
||||
with (
|
||||
patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get,
|
||||
patch.object(manager, "get_service_for_plug", new_callable=AsyncMock) as mock_svc,
|
||||
):
|
||||
mock_get.return_value = mock_plug
|
||||
mock_service = AsyncMock()
|
||||
mock_service.turn_on = AsyncMock(return_value=True)
|
||||
mock_svc.return_value = mock_service
|
||||
|
||||
await manager.on_print_start(printer_id=1, db=mock_db)
|
||||
|
||||
assert mock_plug.auto_off_executed is False
|
||||
assert mock_plug.auto_off is True # Still enabled
|
||||
|
||||
# Step 2: Print completes — auto-off is scheduled
|
||||
with (
|
||||
patch.object(manager, "_get_plug_for_printer", new_callable=AsyncMock) as mock_get,
|
||||
patch.object(manager, "_schedule_delayed_off") as mock_schedule,
|
||||
):
|
||||
mock_get.return_value = mock_plug
|
||||
|
||||
await manager.on_print_complete(printer_id=1, status="completed", db=mock_db)
|
||||
|
||||
mock_schedule.assert_called_once()
|
||||
assert mock_plug.auto_off is True # Still enabled after scheduling
|
||||
|
||||
# Step 3: Auto-off executes via _mark_auto_off_executed
|
||||
with patch("backend.app.core.database.async_session") as mock_session_ctx:
|
||||
mock_db2 = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = mock_plug
|
||||
mock_db2.execute = AsyncMock(return_value=mock_result)
|
||||
mock_db2.commit = AsyncMock()
|
||||
|
||||
mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db2)
|
||||
mock_session_ctx.return_value.__aexit__ = AsyncMock()
|
||||
|
||||
await manager._mark_auto_off_executed(3)
|
||||
|
||||
# KEY ASSERTION: auto_off stays True for persistent mode
|
||||
assert mock_plug.auto_off is True, "Persistent auto_off must survive execution"
|
||||
assert mock_plug.auto_off_pending is False
|
||||
|
||||
|
||||
class TestScheduleLoop:
|
||||
"""Tests for the schedule-based plug control."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user