mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
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
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user