From 99fa902b64f5af3e37bbbe6b0ae1004e7ed44095 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 3 Feb 2026 09:08:12 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 9 +++ backend/app/api/routes/smart_plugs.py | 77 ++++++++----------- frontend/src/components/AddSmartPlugModal.tsx | 49 ++++++++++-- frontend/src/components/SmartPlugCard.tsx | 5 ++ frontend/src/pages/PrintersPage.tsx | 7 +- 5 files changed, 94 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53260fdcf..7b2444518 100644 --- a/CHANGELOG.md +++ b/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` diff --git a/backend/app/api/routes/smart_plugs.py b/backend/app/api/routes/smart_plugs.py index 23c025760..9ddb352ed 100644 --- a/backend/app/api/routes/smart_plugs.py +++ b/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 diff --git a/frontend/src/components/AddSmartPlugModal.tsx b/frontend/src/components/AddSmartPlugModal.tsx index f5436ff35..e38c46cec 100644 --- a/frontend/src/components/AddSmartPlugModal.tsx +++ b/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(plug?.schedule_on_time || ''); const [scheduleOffTime, setScheduleOffTime] = useState(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) { + {/* Printer Card Visibility - only for HA entities */} + {plugType === 'homeassistant' && ( +
+
+
+ +
+ Show on Printer Card +

Display button on printer card

+
+
+ +
+
+ )} + {/* Actions */}
- {/* Script buttons row */} + {/* HA entity buttons row */} {scriptPlugs && scriptPlugs.length > 0 && (
- - Scripts: + + HA:
{scriptPlugs.map(script => (