Parcourir la source

Allow multiple Home Assistant entities per printer (fixes #214)

Both frontend and backend were blocking printers that already had any
smart plug linked, preventing users from adding multiple HA entities
to the same printer.

Changes:
- Frontend: Only filter out printers with existing Tasmota plugs
- Backend: Only check for duplicate Tasmota plugs on create/update
- HA entities (switches, scripts, lights, etc.) can now be linked
  multiple times to the same printer for different automations
- Tasmota plugs remain limited to one per printer (physical device)
- Restored "Show on Printer Card" toggle for HA entities
- Fixed printer card only showing script.* entities; now shows all
  HA entities with the toggle enabled
- HA entities now default to auto_on=False and auto_off=False
- Printer cards now update immediately when HA entities change

Closes #214
maziggy il y a 3 mois
Parent
commit
24a30e2452

+ 9 - 0
CHANGELOG.md

@@ -34,6 +34,15 @@ All notable changes to Bambuddy will be documented in this file.
   - Added locale parity test to ensure English and German stay in sync
 
 ### Fixed
+- **Cannot Link Multiple HA Entities to Same Printer** (Issue #214):
+  - Fixed Home Assistant entities being limited to one per printer
+  - Both frontend and backend were blocking printers that already had any smart plug linked
+  - Now only Tasmota plugs are limited to one per printer (physical device constraint)
+  - Multiple HA entities (switches, scripts, lights, etc.) can be linked to the same printer
+  - Restored "Show on Printer Card" toggle for HA entities to control visibility on printer cards
+  - Fixed printer card only showing `script.*` entities; now shows all HA entities with toggle enabled
+  - HA entities now default to auto_on=False and auto_off=False (appropriate for automations)
+  - Printer cards now update immediately when HA entities are added/modified/deleted
 - **Monthly Comparison Calculation Off** (Issue #229):
   - Fixed filament statistics not accounting for quantity multiplier
   - Monthly comparison chart now correctly multiplies `filament_used_grams` by `quantity`

+ 33 - 44
backend/app/api/routes/smart_plugs.py

@@ -64,21 +64,17 @@ async def create_smart_plug(
             raise HTTPException(400, "Printer not found")
 
         # Check if printer already has a plug assigned
-        # Scripts can coexist with other plugs (they're for multi-device control, not power on/off)
-        is_script = data.plug_type == "homeassistant" and data.ha_entity_id and data.ha_entity_id.startswith("script.")
-        if not is_script:
-            # For non-script plugs, check there's no other non-script plug assigned
-            result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id))
-            existing = result.scalar_one_or_none()
-            if existing:
-                # Allow if existing plug is a script
-                existing_is_script = (
-                    existing.plug_type == "homeassistant"
-                    and existing.ha_entity_id
-                    and existing.ha_entity_id.startswith("script.")
+        # Tasmota plugs: only one per printer (physical power device)
+        # HA entities: allow multiple per printer (for different automations)
+        if data.plug_type == "tasmota":
+            result = await db.execute(
+                select(SmartPlug).where(
+                    SmartPlug.printer_id == data.printer_id,
+                    SmartPlug.plug_type == "tasmota",
                 )
-                if not existing_is_script:
-                    raise HTTPException(400, "This printer already has a smart plug assigned")
+            )
+            if result.scalar_one_or_none():
+                raise HTTPException(400, "This printer already has a Tasmota plug assigned")
 
     # For MQTT plugs, ensure MQTT broker is configured and service is connected
     if data.plug_type == "mqtt":
@@ -110,7 +106,15 @@ async def create_smart_plug(
                     f"Failed to connect to MQTT broker at {mqtt_broker}. Please check your MQTT settings.",
                 )
 
-    plug = SmartPlug(**data.model_dump())
+    plug_data = data.model_dump()
+
+    # For HA entities, default auto_on and auto_off to False
+    # (they're for automations, not power control like Tasmota plugs)
+    if data.plug_type == "homeassistant":
+        plug_data["auto_on"] = False
+        plug_data["auto_off"] = False
+
+    plug = SmartPlug(**plug_data)
     db.add(plug)
     await db.commit()
     await db.refresh(plug)
@@ -181,25 +185,20 @@ async def get_script_plugs_by_printer(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
 ):
-    """Get all HA script plugs assigned to a printer.
+    """Get all HA entities assigned to a printer for display on printer card.
 
-    Returns only script entities (script.*) for the printer that have
+    Returns HA entities (switches, scripts, lights, etc.) for the printer that have
     show_on_printer_card enabled.
-    Used to display "Run Script" buttons alongside the main power plug.
+    Used to display action buttons alongside the main power plug.
     """
     result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
     plugs = result.scalars().all()
 
-    # Filter to only scripts with show_on_printer_card enabled
-    scripts = [
-        plug
-        for plug in plugs
-        if plug.plug_type == "homeassistant"
-        and plug.ha_entity_id
-        and plug.ha_entity_id.startswith("script.")
-        and plug.show_on_printer_card
+    # Filter to HA entities with show_on_printer_card enabled
+    ha_entities = [
+        plug for plug in plugs if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.show_on_printer_card
     ]
-    return scripts
+    return ha_entities
 
 
 # Tasmota Discovery Endpoints
@@ -427,30 +426,20 @@ async def update_smart_plug(
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
 
-        # Check if that printer already has a different plug assigned
-        # Scripts can coexist with other plugs
-        # Determine if the plug being updated is/will be a script
-        new_entity_id = update_data.get("ha_entity_id", plug.ha_entity_id)
+        # Check if that printer already has a different Tasmota plug assigned
+        # Tasmota plugs: only one per printer (physical power device)
+        # HA entities: allow multiple per printer (for different automations)
         new_plug_type = update_data.get("plug_type", plug.plug_type)
-        is_script = new_plug_type == "homeassistant" and new_entity_id and new_entity_id.startswith("script.")
-
-        if not is_script:
+        if new_plug_type == "tasmota":
             result = await db.execute(
                 select(SmartPlug).where(
                     SmartPlug.printer_id == new_printer_id,
                     SmartPlug.id != plug_id,
+                    SmartPlug.plug_type == "tasmota",
                 )
             )
-            existing = result.scalar_one_or_none()
-            if existing:
-                # Allow if existing plug is a script
-                existing_is_script = (
-                    existing.plug_type == "homeassistant"
-                    and existing.ha_entity_id
-                    and existing.ha_entity_id.startswith("script.")
-                )
-                if not existing_is_script:
-                    raise HTTPException(400, "This printer already has a smart plug assigned")
+            if result.scalar_one_or_none():
+                raise HTTPException(400, "This printer already has a Tasmota plug assigned")
 
     # Track old MQTT settings for comparison
     old_plug_type = plug.plug_type

+ 43 - 6
frontend/src/components/AddSmartPlugModal.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useRef } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Radio } from 'lucide-react';
+import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Radio, Eye } from 'lucide-react';
 import { api } from '../api/client';
 import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
 import { Button } from './Button';
@@ -77,8 +77,9 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_time || '');
   const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
 
-  // Switchbar visibility
+  // Visibility options
   const [showInSwitchbar, setShowInSwitchbar] = useState(plug?.show_in_switchbar || false);
+  const [showOnPrinterCard, setShowOnPrinterCard] = useState(plug?.show_on_printer_card ?? true);
 
   // Discovery state
   const [isScanning, setIsScanning] = useState(false);
@@ -251,6 +252,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     mutationFn: (data: SmartPlugCreate) => api.createSmartPlug(data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+      // Also invalidate printer card HA entity queries
+      queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter'] });
       onClose();
     },
     onError: (err: Error) => {
@@ -263,6 +266,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug!.id, data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+      // Also invalidate printer card HA entity queries
+      queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter'] });
       onClose();
     },
     onError: (err: Error) => {
@@ -270,10 +275,17 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     },
   });
 
-  // Filter out printers that already have a plug assigned (except current plug's printer)
+  // For Tasmota plugs, only one per printer (physical device)
+  // For HA scripts, allow multiple per printer
   const availablePrinters = printers?.filter(p => {
-    const hasPlug = existingPlugs?.some(ep => ep.printer_id === p.id && ep.id !== plug?.id);
-    return !hasPlug;
+    if (plugType === 'tasmota') {
+      const hasTasmotaPlug = existingPlugs?.some(
+        ep => ep.printer_id === p.id && ep.id !== plug?.id && ep.plug_type === 'tasmota'
+      );
+      return !hasTasmotaPlug;
+    }
+    // HA scripts can have multiple per printer
+    return true;
   });
 
   const handleSubmit = (e: React.FormEvent) => {
@@ -339,8 +351,9 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       schedule_enabled: scheduleEnabled,
       schedule_on_time: scheduleOnTime || null,
       schedule_off_time: scheduleOffTime || null,
-      // Switchbar
+      // Visibility
       show_in_switchbar: showInSwitchbar,
+      show_on_printer_card: showOnPrinterCard,
     };
 
     if (isEditing) {
@@ -1308,6 +1321,30 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             </div>
           </div>
 
+          {/* Printer Card Visibility - only for HA entities */}
+          {plugType === 'homeassistant' && (
+            <div className="border-t border-bambu-dark-tertiary pt-4">
+              <div className="flex items-center justify-between">
+                <div className="flex items-center gap-2">
+                  <Eye className="w-4 h-4 text-bambu-green" />
+                  <div>
+                    <span className="text-white font-medium">Show on Printer Card</span>
+                    <p className="text-xs text-bambu-gray">Display button on printer card</p>
+                  </div>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={showOnPrinterCard}
+                    onChange={(e) => setShowOnPrinterCard(e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+            </div>
+          )}
+
           {/* Actions */}
           <div className="flex gap-3 pt-2">
             <Button

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

@@ -79,6 +79,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
       // Also invalidate printer-specific smart plug queries to keep PrintersPage in sync
       if (plug.printer_id) {
         queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', plug.printer_id] });
+        queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter', plug.printer_id] });
       }
     },
   });
@@ -88,6 +89,10 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
     mutationFn: () => api.deleteSmartPlug(plug.id),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+      // Also invalidate printer card HA entity queries
+      if (plug.printer_id) {
+        queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter', plug.printer_id] });
+      }
     },
   });
 

+ 4 - 3
frontend/src/pages/PrintersPage.tsx

@@ -42,6 +42,7 @@ import {
   CheckCircle,
   XCircle,
   User,
+  Home,
 } from 'lucide-react';
 
 // Custom Skip Objects icon - arrow jumping over boxes
@@ -2774,11 +2775,11 @@ function PrinterCard({
               </div>
             </div>
 
-            {/* Script buttons row */}
+            {/* HA entity buttons row */}
             {scriptPlugs && scriptPlugs.length > 0 && (
               <div className="flex items-center gap-2 mt-2 pt-2 border-t border-bambu-dark-tertiary/50">
-                <Play className="w-3.5 h-3.5 text-blue-400 flex-shrink-0" />
-                <span className="text-xs text-bambu-gray">Scripts:</span>
+                <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

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-DYRi1tMz.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-lqLnkE85.js"></script>
+    <script type="module" crossorigin src="/assets/index-DYRi1tMz.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CPqcJWwC.css">
   </head>
   <body>

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff