Browse Source

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.
maziggy 2 months ago
parent
commit
c6f62f9cd9

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.3b1] - Unreleased
 
 ### New Features
+- **Persistent Auto-Off for Smart Plugs** ([#826](https://github.com/maziggy/bambuddy/issues/826)) — Smart plugs now have a "Keep Enabled" toggle under Auto Off settings. When enabled, auto-off stays active between prints instead of requiring manual re-enablement after each print (one-shot). Useful for accessories like BentoBox filters on Home Assistant switches that should always power off when a print completes. Default behavior (one-shot) is unchanged. Requested by @AeroMaestro.
 - **Missing Spool Assignment Notification** ([#763](https://github.com/maziggy/bambuddy/issues/763)) — When a print starts and the AMS mapping references tray slots without assigned spools, Bambuddy now shows a warning toast in the frontend and can send push notifications via any configured notification provider. The notification includes the printer name, missing slot labels (e.g. A2, Ext-L), and expected material profile. A new "Missing Spool Assignment" toggle is available under Print Events in notification provider settings (off by default). Fully integrated with i18n (all 7 locales). Contributed by @Keybored02.
 - **Mid-Print Spool Reassignment Tracking** ([#763](https://github.com/maziggy/bambuddy/issues/763)) — Usage tracking now correctly handles spool changes during a print. If a spool assignment is changed after a print starts, the system uses the live assignment for filament deduction; otherwise it falls back to the snapshot taken at print start. This ensures accurate filament tracking even when swapping spools mid-print. Contributed by @Keybored02.
 - **Auto-Link Untagged Inventory Spools on AMS Insert** ([#538](https://github.com/maziggy/bambuddy/issues/538)) — When a Bambu Lab spool is inserted into the AMS and no existing tag match is found, the system now checks if there is an untagged inventory spool with the same material, subtype, and color. If found, the RFID tag is automatically linked to that existing spool instead of creating a duplicate entry. Uses FIFO ordering (oldest spool first) so spools are consumed in purchase order. Matching is case-insensitive. Requested by @wreuel.

+ 12 - 2
backend/app/core/database.py

@@ -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,

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

@@ -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")

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

@@ -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)

+ 7 - 3
backend/app/services/smart_plug_manager.py

@@ -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)
 

+ 143 - 0
backend/tests/unit/services/test_smart_plug_manager.py

@@ -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."""
 

+ 46 - 0
frontend/src/__tests__/components/SmartPlugCard.test.tsx

@@ -43,6 +43,7 @@ const createMockPlug = (overrides: Partial<SmartPlug> = {}): SmartPlug => ({
   enabled: true,
   auto_on: true,
   auto_off: true,
+  auto_off_persistent: false,
   off_delay_mode: 'time',
   off_delay_minutes: 5,
   off_temp_threshold: 70,
@@ -241,6 +242,51 @@ describe('SmartPlugCard', () => {
     });
   });
 
+  describe('persistent auto-off', () => {
+    it('shows Keep Enabled toggle when auto_off is enabled', async () => {
+      const user = userEvent.setup();
+      const plug = createMockPlug({ auto_off: true, auto_off_persistent: false });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      await user.click(screen.getByText('Automation Settings'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Keep Enabled')).toBeInTheDocument();
+        expect(screen.getByText('Stay enabled between prints instead of one-shot')).toBeInTheDocument();
+      });
+    });
+
+    it('does not show Keep Enabled toggle when auto_off is disabled', async () => {
+      const user = userEvent.setup();
+      const plug = createMockPlug({ auto_off: false });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      await user.click(screen.getByText('Automation Settings'));
+
+      await waitFor(() => {
+        expect(screen.queryByText('Keep Enabled')).not.toBeInTheDocument();
+      });
+    });
+
+    it('shows Keep Enabled toggle for HA plugs with auto_off enabled', async () => {
+      const user = userEvent.setup();
+      const plug = createMockPlug({
+        plug_type: 'homeassistant',
+        ip_address: null,
+        ha_entity_id: 'switch.bentobox_filter',
+        auto_off: true,
+        auto_off_persistent: true,
+      });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      await user.click(screen.getByText('Automation Settings'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Keep Enabled')).toBeInTheDocument();
+      });
+    });
+  });
+
   describe('disabled state', () => {
     it('renders plug even when disabled', () => {
       const plug = createMockPlug({ enabled: false });

+ 3 - 0
frontend/src/api/client.ts

@@ -1074,6 +1074,7 @@ export interface SmartPlug {
   enabled: boolean;
   auto_on: boolean;
   auto_off: boolean;
+  auto_off_persistent: boolean;
   off_delay_mode: 'time' | 'temperature';
   off_delay_minutes: number;
   off_temp_threshold: number;
@@ -1128,6 +1129,7 @@ export interface SmartPlugCreate {
   enabled?: boolean;
   auto_on?: boolean;
   auto_off?: boolean;
+  auto_off_persistent?: boolean;
   off_delay_mode?: 'time' | 'temperature';
   off_delay_minutes?: number;
   off_temp_threshold?: number;
@@ -1174,6 +1176,7 @@ export interface SmartPlugUpdate {
   enabled?: boolean;
   auto_on?: boolean;
   auto_off?: boolean;
+  auto_off_persistent?: boolean;
   off_delay_mode?: 'time' | 'temperature';
   off_delay_minutes?: number;
   off_temp_threshold?: number;

+ 19 - 0
frontend/src/components/SmartPlugCard.tsx

@@ -361,6 +361,25 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                 </label>
               </div>
 
+              {/* Auto Off Persistent */}
+              {plug.auto_off && (
+                <div className="flex items-center justify-between pl-4 border-l-2 border-bambu-dark-tertiary">
+                  <div>
+                    <p className="text-sm text-white">{t('smartPlugs.autoOffPersistent')}</p>
+                    <p className="text-xs text-bambu-gray">{t('smartPlugs.autoOffPersistentDescription')}</p>
+                  </div>
+                  <label className="relative inline-flex items-center cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={plug.auto_off_persistent}
+                      onChange={(e) => updateMutation.mutate({ auto_off_persistent: e.target.checked })}
+                      className="sr-only peer"
+                    />
+                    <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
+                  </label>
+                </div>
+              )}
+
               {/* Delay Mode */}
               {plug.auto_off && (
                 <div className="space-y-3 pl-4 border-l-2 border-bambu-dark-tertiary">

+ 2 - 0
frontend/src/i18n/locales/de.ts

@@ -3671,6 +3671,8 @@ export default {
     autoOnDescription: 'Einschalten wenn Druck startet',
     autoOff: 'Auto Aus',
     autoOffDescription: 'Ausschalten wenn Druck abgeschlossen (einmalig)',
+    autoOffPersistent: 'Aktiviert lassen',
+    autoOffPersistentDescription: 'Zwischen Drucken aktiviert bleiben statt einmalig',
     turnOffDelayMode: 'Ausschaltverzögerungsmodus',
     time: 'Zeit',
     temp: 'Temp',

+ 2 - 0
frontend/src/i18n/locales/en.ts

@@ -3676,6 +3676,8 @@ export default {
     autoOnDescription: 'Turn on when print starts',
     autoOff: 'Auto Off',
     autoOffDescription: 'Turn off when print completes (one-shot)',
+    autoOffPersistent: 'Keep Enabled',
+    autoOffPersistentDescription: 'Stay enabled between prints instead of one-shot',
     turnOffDelayMode: 'Turn Off Delay Mode',
     time: 'Time',
     temp: 'Temp',

+ 2 - 0
frontend/src/i18n/locales/fr.ts

@@ -3663,6 +3663,8 @@ export default {
     autoOnDescription: 'Allumer au démarrage de l\'impression',
     autoOff: 'Auto Off',
     autoOffDescription: 'Éteindre à la fin de l\'impression (unique)',
+    autoOffPersistent: 'Garder activé',
+    autoOffPersistentDescription: 'Rester activé entre les impressions au lieu d\'une seule fois',
     turnOffDelayMode: 'Mode de délai d\'extinction',
     time: 'Temps',
     temp: 'Temp',

+ 2 - 0
frontend/src/i18n/locales/it.ts

@@ -3662,6 +3662,8 @@ export default {
     autoOnDescription: 'Accendi quando inizia la stampa',
     autoOff: 'Auto Off',
     autoOffDescription: 'Spegni quando la stampa è completata (una tantum)',
+    autoOffPersistent: 'Mantieni attivo',
+    autoOffPersistentDescription: 'Resta attivo tra le stampe invece di una tantum',
     turnOffDelayMode: 'Modalità ritardo spegnimento',
     time: 'Tempo',
     temp: 'Temp',

+ 2 - 0
frontend/src/i18n/locales/ja.ts

@@ -3675,6 +3675,8 @@ export default {
     autoOnDescription: '印刷開始時にオンにする',
     autoOff: '自動オフ',
     autoOffDescription: '印刷完了時にオフにする(ワンショット)',
+    autoOffPersistent: '有効のまま維持',
+    autoOffPersistentDescription: 'ワンショットではなく印刷間で有効のまま維持',
     turnOffDelayMode: 'オフ遅延モード',
     time: '時間',
     temp: '温度',

+ 2 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3662,6 +3662,8 @@ export default {
     autoOnDescription: 'Ligar quando a impressão iniciar',
     autoOff: 'Auto Desligar',
     autoOffDescription: 'Desligar quando a impressão terminar (única vez)',
+    autoOffPersistent: 'Manter ativado',
+    autoOffPersistentDescription: 'Permanecer ativado entre impressões em vez de única vez',
     turnOffDelayMode: 'Modo de atraso para desligar',
     time: 'Tempo',
     temp: 'Temp',

+ 2 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3662,6 +3662,8 @@ export default {
     autoOnDescription: '打印开始时开启',
     autoOff: '自动关闭',
     autoOffDescription: '打印完成时关闭(一次性)',
+    autoOffPersistent: '保持启用',
+    autoOffPersistentDescription: '在打印之间保持启用而非一次性',
     turnOffDelayMode: '关闭延迟模式',
     time: '时间',
     temp: '温度',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BXqghasR.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DBBfrr2z.js"></script>
+    <script type="module" crossorigin src="/assets/index-BXqghasR.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DWwO6mIe.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff