Browse Source

Fix HA switch badge toggle and multi-plug crash

  Non-script HA entities (switch/light/input_boolean) on the printer card
  always sent turn_on when clicked, which is a no-op when the switch is
  already on. Now sends toggle for non-script entities so the badge click
  actually changes the switch state. Script entities still use turn_on.

  Also fix SmartPlugManager._get_plug_for_printer() using
  scalar_one_or_none() which crashes with MultipleResultsFound when
  multiple plugs are assigned to the same printer (e.g., Tasmota + HA
  switch). Now fetches all plugs and returns the main non-script plug.

  Add tests for both fixes: _get_plug_for_printer multi-plug handling
  (5 tests) and canonicalFilamentType/filamentTypesCompatible/autoMatch
  PA-CF equivalence (8 tests).
maziggy 2 months ago
parent
commit
ecc9ec11f1

+ 2 - 0
CHANGELOG.md

@@ -35,6 +35,8 @@ All notable changes to Bambuddy will be documented in this file.
 - **iOS/iPadOS Cannot Reposition Floating Camera** ([#687](https://github.com/maziggy/bambuddy/issues/687)) — The floating camera viewer (embedded camera window on the dashboard) could not be dragged or resized on iOS/iPadOS because it only handled mouse events. Touch input scrolled the page underneath instead of moving the camera window. Added touch event support (`touchstart`/`touchmove`/`touchend`) to both the header drag handle and the resize handle, with `preventDefault` to stop page scrolling during drag. Reported by @dsmitty166.
 - **PA-CF / PA12-CF / PAHT-CF Not Treated as Compatible** ([#688](https://github.com/maziggy/bambuddy/issues/688)) — Bambu Lab firmware treats PA-CF, PA12-CF, and PAHT-CF as interchangeable, but the print scheduler and filament override UI used exact string matching. If a 3MF required PA-CF but the AMS had PA12-CF loaded, the scheduler wouldn't assign the job and the filament override dropdown was empty/disabled. Added a filament type equivalence system so these PA variants are treated as compatible in scheduler assignment, AMS slot matching, force color match validation, and the filament override dropdown. Reported by @aneopsy.
 - **Force Color Match Toggle Click Target Too Large** ([#688](https://github.com/maziggy/bambuddy/issues/688)) — In the Schedule Print modal, clicking anywhere on the "Force color match" row toggled the checkbox, not just the checkbox and its label. The click target now covers only the checkbox, icon, and label text. Reported by @aneopsy.
+- **HA Switch Badge Always Sends Turn On Instead of Toggle** — Clicking a non-script Home Assistant entity (switch, light, input_boolean) on the printer card always sent `turn_on`, which is a no-op when the switch is already on. Now sends `toggle` for non-script entities so the badge click actually toggles the switch state. Script entities still use `turn_on` (stateless trigger).
+- **Multiple Plugs Per Printer Crashes Auto-On/Off** — When multiple smart plugs were assigned to the same printer (e.g., a Tasmota plug + an HA switch), the auto-on/auto-off handler called `scalar_one_or_none()` which raises `MultipleResultsFound`. Now fetches all plugs and returns the main (non-script) power plug, matching the API route behavior.
 - **Debug Logging Endpoint 500 Error** — The `GET /api/v1/support/debug-logging` endpoint returned a 500 Internal Server Error when the database contained a timezone-aware timestamp written by a previous version. The duration calculation subtracted a timezone-aware datetime from a naive `datetime.now()`, raising `TypeError`. Now strips timezone info when reading the stored timestamp.
 - **Bed Cooled Notification Never Fires** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — The bed cooldown monitor always timed out after 30 minutes without sending a notification. After print completion, P1S (and likely other models) sends partial MQTT status updates that don't include `bed_temper`, so the cached bed temperature stayed frozen at the end-of-print value and never dropped below the threshold. The monitor now sends periodic `pushall` commands to the printer to force fresh temperature data. Also added debug logging to the polling loop for future diagnostics.
 - **Notification Provider Missing Event Toggles on Create** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — When creating a new notification provider, the `on_bed_cooled` toggle and all 7 queue event toggles (`on_queue_job_added`, `on_queue_job_assigned`, `on_queue_job_started`, `on_queue_job_waiting`, `on_queue_job_skipped`, `on_queue_job_failed`, `on_queue_completed`) were silently discarded. The create endpoint manually listed each field but omitted these 8 toggles, so they always defaulted to `false` regardless of user selection. Editing an existing provider worked correctly.

+ 20 - 2
backend/app/services/smart_plug_manager.py

@@ -132,11 +132,29 @@ class SmartPlugManager:
             await db.commit()
 
     async def _get_plug_for_printer(self, printer_id: int, db: AsyncSession) -> "SmartPlug | None":
-        """Get the smart plug linked to a printer."""
+        """Get the main (non-script) smart plug linked to a printer.
+
+        When multiple plugs are assigned (e.g., a power plug + secondary HA switch),
+        returns the main power plug for automation control.
+        """
         from backend.app.models.smart_plug import SmartPlug
 
         result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-        return result.scalar_one_or_none()
+        plugs = result.scalars().all()
+
+        if not plugs:
+            return None
+
+        # Prefer non-script, non-secondary plugs (main power plug)
+        for plug in plugs:
+            is_script = (
+                plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
+            )
+            if not is_script:
+                return plug
+
+        # All are scripts, return the first one
+        return plugs[0]
 
     async def on_print_start(self, printer_id: int, db: AsyncSession):
         """Called when a print starts - turn on plug if configured."""

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

@@ -325,6 +325,98 @@ class TestSmartPlugManager:
             mock_loop.assert_not_called()  # Should not call _schedule_loop
 
 
+class TestGetPlugForPrinter:
+    """Tests for _get_plug_for_printer with multiple plugs per printer."""
+
+    @pytest.fixture
+    def manager(self):
+        return SmartPlugManager()
+
+    @pytest.mark.asyncio
+    async def test_returns_none_when_no_plugs(self, manager):
+        """Verify None is returned when no plugs are linked to printer."""
+        mock_db = AsyncMock()
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = []
+        mock_db.execute = AsyncMock(return_value=mock_result)
+
+        result = await manager._get_plug_for_printer(1, mock_db)
+        assert result is None
+
+    @pytest.mark.asyncio
+    async def test_returns_single_plug(self, manager):
+        """Verify single plug is returned directly."""
+        plug = MagicMock()
+        plug.plug_type = "tasmota"
+        plug.ha_entity_id = None
+
+        mock_db = AsyncMock()
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = [plug]
+        mock_db.execute = AsyncMock(return_value=mock_result)
+
+        result = await manager._get_plug_for_printer(1, mock_db)
+        assert result is plug
+
+    @pytest.mark.asyncio
+    async def test_prefers_main_plug_over_script(self, manager):
+        """Verify main power plug is returned when both main and script exist."""
+        script_plug = MagicMock()
+        script_plug.plug_type = "homeassistant"
+        script_plug.ha_entity_id = "script.pre_print"
+
+        main_plug = MagicMock()
+        main_plug.plug_type = "tasmota"
+        main_plug.ha_entity_id = None
+
+        mock_db = AsyncMock()
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = [script_plug, main_plug]
+        mock_db.execute = AsyncMock(return_value=mock_result)
+
+        result = await manager._get_plug_for_printer(1, mock_db)
+        assert result is main_plug
+
+    @pytest.mark.asyncio
+    async def test_handles_multiple_non_script_plugs(self, manager):
+        """Verify no crash when multiple non-script plugs exist (e.g., Tasmota + HA switch)."""
+        tasmota_plug = MagicMock()
+        tasmota_plug.plug_type = "tasmota"
+        tasmota_plug.ha_entity_id = None
+
+        ha_switch = MagicMock()
+        ha_switch.plug_type = "homeassistant"
+        ha_switch.ha_entity_id = "switch.bathroom"
+
+        mock_db = AsyncMock()
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = [tasmota_plug, ha_switch]
+        mock_db.execute = AsyncMock(return_value=mock_result)
+
+        result = await manager._get_plug_for_printer(1, mock_db)
+        # Should return first non-script plug (tasmota), not crash
+        assert result is tasmota_plug
+
+    @pytest.mark.asyncio
+    async def test_returns_first_script_when_all_are_scripts(self, manager):
+        """Verify first script is returned when only scripts are linked."""
+        script1 = MagicMock()
+        script1.plug_type = "homeassistant"
+        script1.ha_entity_id = "script.heat_chamber"
+
+        script2 = MagicMock()
+        script2.plug_type = "homeassistant"
+        script2.ha_entity_id = "script.exhaust_fan"
+
+        mock_db = AsyncMock()
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = [script1, script2]
+        mock_db.execute = AsyncMock(return_value=mock_result)
+
+        result = await manager._get_plug_for_printer(1, mock_db)
+        assert result is script1
+
+
 class TestScheduleLoop:
     """Tests for the schedule-based plug control."""
 

+ 66 - 0
frontend/src/__tests__/components/PrinterSelector.test.ts

@@ -10,6 +10,8 @@
 import { describe, it, expect } from 'vitest';
 import {
   autoMatchFilament,
+  canonicalFilamentType,
+  filamentTypesCompatible,
   filterFilamentsByNozzle,
 } from '../../utils/amsHelpers';
 import type { LoadedFilament, FilamentRequirement } from '../../hooks/useFilamentMapping';
@@ -50,6 +52,50 @@ const H2D_FILAMENTS: LoadedFilament[] = [
   makeFilament({ globalTrayId: 5, type: 'PLA', color: '#FF0000', colorName: 'Red', amsId: 1, trayId: 1, label: 'AMS2-T2', extruderId: 0 }),
 ];
 
+// -- canonicalFilamentType / filamentTypesCompatible -------------------------
+
+describe('canonicalFilamentType', () => {
+  it('maps PA-CF variants to the same canonical type', () => {
+    const canonical = canonicalFilamentType('PA-CF');
+    expect(canonicalFilamentType('PA12-CF')).toBe(canonical);
+    expect(canonicalFilamentType('PAHT-CF')).toBe(canonical);
+  });
+
+  it('is case-insensitive', () => {
+    expect(canonicalFilamentType('pa-cf')).toBe(canonicalFilamentType('PA-CF'));
+    expect(canonicalFilamentType('Pa12-Cf')).toBe(canonicalFilamentType('PA12-CF'));
+  });
+
+  it('returns the type unchanged for non-equivalent types', () => {
+    expect(canonicalFilamentType('PLA')).toBe('PLA');
+    expect(canonicalFilamentType('PETG')).toBe('PETG');
+    expect(canonicalFilamentType('ABS')).toBe('ABS');
+  });
+
+  it('returns empty string for undefined/empty input', () => {
+    expect(canonicalFilamentType(undefined)).toBe('');
+    expect(canonicalFilamentType('')).toBe('');
+  });
+});
+
+describe('filamentTypesCompatible', () => {
+  it('treats PA-CF and PA12-CF as compatible', () => {
+    expect(filamentTypesCompatible('PA-CF', 'PA12-CF')).toBe(true);
+  });
+
+  it('treats PA-CF and PAHT-CF as compatible', () => {
+    expect(filamentTypesCompatible('PA-CF', 'PAHT-CF')).toBe(true);
+  });
+
+  it('treats PLA and PETG as incompatible', () => {
+    expect(filamentTypesCompatible('PLA', 'PETG')).toBe(false);
+  });
+
+  it('treats same types as compatible', () => {
+    expect(filamentTypesCompatible('PLA', 'PLA')).toBe(true);
+  });
+});
+
 // -- filterFilamentsByNozzle -------------------------------------------------
 
 describe('filterFilamentsByNozzle', () => {
@@ -129,4 +175,24 @@ describe('autoMatchFilament', () => {
     expect(result).toBeDefined();
     expect(result!.globalTrayId).toBe(5);
   });
+
+  it('matches PA-CF requirement to PA12-CF filament — #688', () => {
+    const filaments: LoadedFilament[] = [
+      makeFilament({ globalTrayId: 0, type: 'PA12-CF', color: '#000000', colorName: 'Black' }),
+    ];
+    const req = makeReq({ type: 'PA-CF', color: '#000000' });
+    const result = autoMatchFilament(req, filaments, new Set());
+    expect(result).toBeDefined();
+    expect(result!.globalTrayId).toBe(0);
+  });
+
+  it('matches PAHT-CF requirement to PA-CF filament — #688', () => {
+    const filaments: LoadedFilament[] = [
+      makeFilament({ globalTrayId: 0, type: 'PA-CF', color: '#333333', colorName: 'Dark Gray' }),
+    ];
+    const req = makeReq({ type: 'PAHT-CF', color: '#333333' });
+    const result = autoMatchFilament(req, filaments, new Set());
+    expect(result).toBeDefined();
+    expect(result!.globalTrayId).toBe(0);
+  });
 });

+ 17 - 14
frontend/src/pages/PrintersPage.tsx

@@ -1924,9 +1924,9 @@ function PrinterCard({
     },
   });
 
-  // Run script mutation
+  // Run HA entity mutation — scripts use 'on' (trigger), switches use 'toggle'
   const runScriptMutation = useMutation({
-    mutationFn: (scriptId: number) => api.controlSmartPlug(scriptId, 'on'),
+    mutationFn: ({ id, action }: { id: number; action: 'on' | 'toggle' }) => api.controlSmartPlug(id, action),
     onSuccess: () => {
       showToast(t('printers.toast.scriptTriggered'));
     },
@@ -3977,18 +3977,21 @@ function PrinterCard({
                 <Home className="w-3.5 h-3.5 text-blue-400 flex-shrink-0" />
                 <span className="text-xs text-bambu-gray">HA:</span>
                 <div className="flex flex-wrap gap-1">
-                  {scriptPlugs.map(script => (
-                    <button
-                      key={script.id}
-                      onClick={() => runScriptMutation.mutate(script.id)}
-                      disabled={runScriptMutation.isPending}
-                      title={`Run ${script.ha_entity_id}`}
-                      className="px-2 py-0.5 text-xs bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 rounded transition-colors flex items-center gap-1"
-                    >
-                      <Play className="w-2.5 h-2.5" />
-                      {script.name}
-                    </button>
-                  ))}
+                  {scriptPlugs.map(script => {
+                    const isScript = script.ha_entity_id?.startsWith('script.');
+                    return (
+                      <button
+                        key={script.id}
+                        onClick={() => runScriptMutation.mutate({ id: script.id, action: isScript ? 'on' : 'toggle' })}
+                        disabled={runScriptMutation.isPending}
+                        title={`${isScript ? 'Run' : 'Toggle'} ${script.ha_entity_id}`}
+                        className="px-2 py-0.5 text-xs bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 rounded transition-colors flex items-center gap-1"
+                      >
+                        <Play className="w-2.5 h-2.5" />
+                        {script.name}
+                      </button>
+                    );
+                  })}
                 </div>
               </div>
             )}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DNnmE4kS.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-Bl2AZAaE.js"></script>
+    <script type="module" crossorigin src="/assets/index-DNnmE4kS.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index--YKaUCwD.css">
   </head>
   <body>

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