Files
bambuddy/frontend/src/components/AddSmartPlugModal.tsx
T
maziggy 56accd24de fix(smart-plugs): don't blank printer state when an accessory plug switches off (#2629)
An end-of-print auto-off on a plug that powers a filter fan marked the linked
printer offline and forced its state to "unknown". The mark was unrecoverable:
connected heals on the next MQTT message but state does not (only frames
carrying gcode_state rewrite it, and steady-state push_status frames are
partial), so the printer stayed "unknown" until a manual Force Refresh and the
queue never dispatched to it again.

The offline mark is now an explicit presumption: mark_power_off records the
state it overwrites and _on_message undoes it as soon as the printer sends
another report on its own topic, since inbound traffic proves the power was
never cut. A reconnect discards the saved state, so a genuine power cut is
unaffected. Each plug also gains a controls_printer_power flag (default true,
backfilled) that gates all five power-off paths, and the queue's power-on step
now picks the flagged plug instead of whichever linked plug came first.
2026-07-22 09:57:27 +02:00

1709 lines
88 KiB
TypeScript

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, Eye, Globe } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
import { Button } from './Button';
interface AddSmartPlugModalProps {
plug?: SmartPlug | null;
onClose: () => void;
}
export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const isEditing = !!plug;
// Plug type selection
const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant' | 'mqtt' | 'rest'>(plug?.plug_type || 'tasmota');
const [name, setName] = useState(plug?.name || '');
// Tasmota fields
const [ipAddress, setIpAddress] = useState(plug?.ip_address || '');
const [username, setUsername] = useState(plug?.username || '');
const [password, setPassword] = useState(plug?.password || '');
// Home Assistant fields
const [haEntityId, setHaEntityId] = useState(plug?.ha_entity_id || '');
// MQTT fields - Power
const [mqttPowerTopic, setMqttPowerTopic] = useState(plug?.mqtt_power_topic || plug?.mqtt_topic || '');
const [mqttPowerPath, setMqttPowerPath] = useState(plug?.mqtt_power_path || '');
const [mqttPowerMultiplier, setMqttPowerMultiplier] = useState<string>(
(plug?.mqtt_power_multiplier ?? plug?.mqtt_multiplier ?? 1).toString()
);
// MQTT fields - Energy
const [mqttEnergyTopic, setMqttEnergyTopic] = useState(plug?.mqtt_energy_topic || '');
const [mqttEnergyPath, setMqttEnergyPath] = useState(plug?.mqtt_energy_path || '');
const [mqttEnergyMultiplier, setMqttEnergyMultiplier] = useState<string>(
(plug?.mqtt_energy_multiplier ?? 1).toString()
);
// MQTT fields - State
const [mqttStateTopic, setMqttStateTopic] = useState(plug?.mqtt_state_topic || '');
const [mqttStatePath, setMqttStatePath] = useState(plug?.mqtt_state_path || '');
const [mqttStateOnValue, setMqttStateOnValue] = useState(plug?.mqtt_state_on_value || '');
// REST fields
const [restOnUrl, setRestOnUrl] = useState(plug?.rest_on_url || '');
const [restOnBody, setRestOnBody] = useState(plug?.rest_on_body || '');
const [restOffUrl, setRestOffUrl] = useState(plug?.rest_off_url || '');
const [restOffBody, setRestOffBody] = useState(plug?.rest_off_body || '');
const [restMethod, setRestMethod] = useState(plug?.rest_method || 'POST');
const [restHeaders, setRestHeaders] = useState(plug?.rest_headers || '');
const [restStatusUrl, setRestStatusUrl] = useState(plug?.rest_status_url || '');
const [restStatusPath, setRestStatusPath] = useState(plug?.rest_status_path || '');
const [restStatusOnValue, setRestStatusOnValue] = useState(plug?.rest_status_on_value || '');
const [restPowerUrl, setRestPowerUrl] = useState(plug?.rest_power_url || '');
const [restPowerPath, setRestPowerPath] = useState(plug?.rest_power_path || '');
const [restPowerMultiplier, setRestPowerMultiplier] = useState<string>((plug?.rest_power_multiplier ?? 1).toString());
const [restEnergyUrl, setRestEnergyUrl] = useState(plug?.rest_energy_url || '');
const [restEnergyPath, setRestEnergyPath] = useState(plug?.rest_energy_path || '');
const [restEnergyMultiplier, setRestEnergyMultiplier] = useState<string>((plug?.rest_energy_multiplier ?? 1).toString());
const [restEnergyTotalPath, setRestEnergyTotalPath] = useState(plug?.rest_energy_total_path || '');
const [restEnergyTotalMultiplier, setRestEnergyTotalMultiplier] = useState<string>((plug?.rest_energy_total_multiplier ?? 1).toString());
// HA energy sensor entities (optional)
const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || '');
const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_entity || '');
const [haEnergyTotalEntity, setHaEnergyTotalEntity] = useState(plug?.ha_energy_total_entity || '');
// HA entity search
const [haEntitySearch, setHaEntitySearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [isEntityDropdownOpen, setIsEntityDropdownOpen] = useState(false);
const entityDropdownRef = useRef<HTMLDivElement>(null);
// Energy sensor search states
const [powerSensorSearch, setPowerSensorSearch] = useState('');
const [isPowerDropdownOpen, setIsPowerDropdownOpen] = useState(false);
const powerDropdownRef = useRef<HTMLDivElement>(null);
const [energyTodaySearch, setEnergyTodaySearch] = useState('');
const [isEnergyTodayDropdownOpen, setIsEnergyTodayDropdownOpen] = useState(false);
const energyTodayDropdownRef = useRef<HTMLDivElement>(null);
const [energyTotalSearch, setEnergyTotalSearch] = useState('');
const [isEnergyTotalDropdownOpen, setIsEnergyTotalDropdownOpen] = useState(false);
const energyTotalDropdownRef = useRef<HTMLDivElement>(null);
const [printerId, setPrinterId] = useState<number | null>(plug?.printer_id || null);
// #2629: defaults to true (a plug linked to a printer usually powers it);
// users turn it off for accessories like a filter fan or chamber light.
const [controlsPrinterPower, setControlsPrinterPower] = useState(plug?.controls_printer_power ?? true);
const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
const [error, setError] = useState<string | null>(null);
// Power alert settings
const [powerAlertEnabled, setPowerAlertEnabled] = useState(plug?.power_alert_enabled || false);
const [powerAlertHigh, setPowerAlertHigh] = useState<string>(plug?.power_alert_high?.toString() || '');
const [powerAlertLow, setPowerAlertLow] = useState<string>(plug?.power_alert_low?.toString() || '');
// Schedule settings
const [scheduleEnabled, setScheduleEnabled] = useState(plug?.schedule_enabled || false);
const [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_time || '');
const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
// 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);
const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 });
const [discoveredDevices, setDiscoveredDevices] = useState<DiscoveredTasmotaDevice[]>([]);
const scanPollRef = useRef<NodeJS.Timeout | null>(null);
// Fetch printers for linking
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
// Fetch existing plugs to check for conflicts
const { data: existingPlugs } = useQuery({
queryKey: ['smart-plugs'],
queryFn: api.getSmartPlugs,
});
// Fetch settings to check if HA is configured
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
// Check if HA is properly configured
const haConfigured = !!(settings?.ha_enabled && settings?.ha_url && settings?.ha_token);
// Debounce search input
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(haEntitySearch);
}, 300);
return () => clearTimeout(timer);
}, [haEntitySearch]);
// Close dropdowns when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (entityDropdownRef.current && !entityDropdownRef.current.contains(e.target as Node)) {
setIsEntityDropdownOpen(false);
}
if (powerDropdownRef.current && !powerDropdownRef.current.contains(e.target as Node)) {
setIsPowerDropdownOpen(false);
}
if (energyTodayDropdownRef.current && !energyTodayDropdownRef.current.contains(e.target as Node)) {
setIsEnergyTodayDropdownOpen(false);
}
if (energyTotalDropdownRef.current && !energyTotalDropdownRef.current.contains(e.target as Node)) {
setIsEnergyTotalDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Fetch Home Assistant entities when in HA mode AND HA is configured
const { data: haEntities, isLoading: haEntitiesLoading, error: haEntitiesError } = useQuery({
queryKey: ['ha-entities', debouncedSearch],
queryFn: () => api.getHAEntities(debouncedSearch || undefined),
enabled: plugType === 'homeassistant' && haConfigured,
retry: false,
staleTime: 0,
});
// Fetch Home Assistant sensor entities for energy monitoring
const { data: haSensorEntities } = useQuery({
queryKey: ['ha-sensor-entities'],
queryFn: api.getHASensorEntities,
enabled: plugType === 'homeassistant' && haConfigured,
retry: false,
staleTime: 0,
});
// Close on Escape key and cleanup scan polling
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
if (scanPollRef.current) {
clearInterval(scanPollRef.current);
}
};
}, [onClose]);
// Start scanning for Tasmota devices (auto-detects network)
const startScan = async () => {
setIsScanning(true);
setDiscoveredDevices([]);
setScanProgress({ scanned: 0, total: 0 });
setError(null);
try {
await api.startTasmotaScan();
// Poll function to fetch status and devices
const pollStatus = async () => {
try {
const status = await api.getTasmotaScanStatus();
setScanProgress({ scanned: status.scanned, total: status.total });
const devices = await api.getDiscoveredTasmotaDevices();
setDiscoveredDevices(devices);
if (!status.running) {
setIsScanning(false);
if (scanPollRef.current) {
clearInterval(scanPollRef.current);
scanPollRef.current = null;
}
}
} catch (e) {
console.error('Polling error:', e);
}
};
// Poll immediately, then every 500ms
await pollStatus();
scanPollRef.current = setInterval(pollStatus, 500);
} catch (err) {
setIsScanning(false);
const errorMsg = err instanceof Error ? err.message : (typeof err === 'string' ? err : JSON.stringify(err));
setError(errorMsg || t('smartPlugs.failedToStartScan'));
}
};
// Stop scanning
const stopScan = async () => {
try {
await api.stopTasmotaScan();
} catch {
// Ignore stop errors
}
setIsScanning(false);
if (scanPollRef.current) {
clearInterval(scanPollRef.current);
scanPollRef.current = null;
}
};
// Select a discovered device
const selectDevice = (device: DiscoveredTasmotaDevice) => {
setIpAddress(device.ip_address);
setName(device.name);
setTestResult(null);
};
// Test connection mutation
const testMutation = useMutation({
mutationFn: () => api.testSmartPlugConnection(ipAddress, username || null, password || null),
onSuccess: (result) => {
setTestResult(result);
setError(null);
// Auto-fill name from device if empty
if (!name && result.device_name) {
setName(result.device_name);
}
},
onError: (err: Error) => {
setTestResult(null);
setError(err.message);
},
});
// Create mutation
const createMutation = useMutation({
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) => {
setError(err.message);
},
});
// Update mutation
const updateMutation = useMutation({
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) => {
setError(err.message);
},
});
// For Tasmota plugs, only one per printer (physical device)
// For HA scripts, allow multiple per printer
const availablePrinters = printers?.filter(p => {
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) => {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError(t('smartPlugs.nameRequired'));
return;
}
if (plugType === 'tasmota' && !ipAddress.trim()) {
setError('IP address is required for Tasmota plugs');
return;
}
if (plugType === 'homeassistant' && !haEntityId) {
setError(t('smartPlugs.entityRequired'));
return;
}
if (plugType === 'mqtt') {
// Check that at least one topic is configured (path is optional)
const hasPower = mqttPowerTopic.trim();
const hasEnergy = mqttEnergyTopic.trim();
const hasState = mqttStateTopic.trim();
if (!hasPower && !hasEnergy && !hasState) {
setError(t('smartPlugs.mqttTopicRequired'));
return;
}
}
if (plugType === 'rest') {
if (!restOnUrl.trim() && !restOffUrl.trim()) {
setError(t('smartPlugs.restUrlRequired'));
return;
}
}
const data = {
name: name.trim(),
plug_type: plugType,
ip_address: plugType === 'tasmota' ? ipAddress.trim() : null,
ha_entity_id: plugType === 'homeassistant' ? haEntityId : null,
// HA energy sensor entities (optional)
ha_power_entity: plugType === 'homeassistant' ? (haPowerEntity || null) : null,
ha_energy_today_entity: plugType === 'homeassistant' ? (haEnergyTodayEntity || null) : null,
ha_energy_total_entity: plugType === 'homeassistant' ? (haEnergyTotalEntity || null) : null,
// MQTT power fields
mqtt_power_topic: plugType === 'mqtt' ? (mqttPowerTopic.trim() || null) : null,
mqtt_power_path: plugType === 'mqtt' ? (mqttPowerPath.trim() || null) : null,
mqtt_power_multiplier: plugType === 'mqtt' ? (parseFloat(mqttPowerMultiplier) || 1) : 1,
// MQTT energy fields
mqtt_energy_topic: plugType === 'mqtt' ? (mqttEnergyTopic.trim() || null) : null,
mqtt_energy_path: plugType === 'mqtt' ? (mqttEnergyPath.trim() || null) : null,
mqtt_energy_multiplier: plugType === 'mqtt' ? (parseFloat(mqttEnergyMultiplier) || 1) : 1,
// MQTT state fields
mqtt_state_topic: plugType === 'mqtt' ? (mqttStateTopic.trim() || null) : null,
mqtt_state_path: plugType === 'mqtt' ? (mqttStatePath.trim() || null) : null,
mqtt_state_on_value: plugType === 'mqtt' ? (mqttStateOnValue.trim() || null) : null,
// REST fields
rest_on_url: plugType === 'rest' ? (restOnUrl.trim() || null) : null,
rest_on_body: plugType === 'rest' ? (restOnBody.trim() || null) : null,
rest_off_url: plugType === 'rest' ? (restOffUrl.trim() || null) : null,
rest_off_body: plugType === 'rest' ? (restOffBody.trim() || null) : null,
rest_method: plugType === 'rest' ? restMethod : null,
rest_headers: plugType === 'rest' ? (restHeaders.trim() || null) : null,
rest_status_url: plugType === 'rest' ? (restStatusUrl.trim() || null) : null,
rest_status_path: plugType === 'rest' ? (restStatusPath.trim() || null) : null,
rest_status_on_value: plugType === 'rest' ? (restStatusOnValue.trim() || null) : null,
rest_power_url: plugType === 'rest' ? (restPowerUrl.trim() || null) : null,
rest_power_path: plugType === 'rest' ? (restPowerPath.trim() || null) : null,
rest_power_multiplier: plugType === 'rest' ? (parseFloat(restPowerMultiplier) || 1) : 1,
rest_energy_url: plugType === 'rest' ? (restEnergyUrl.trim() || null) : null,
rest_energy_path: plugType === 'rest' ? (restEnergyPath.trim() || null) : null,
rest_energy_multiplier: plugType === 'rest' ? (parseFloat(restEnergyMultiplier) || 1) : 1,
rest_energy_total_path: plugType === 'rest' ? (restEnergyTotalPath.trim() || null) : null,
rest_energy_total_multiplier: plugType === 'rest' ? (parseFloat(restEnergyTotalMultiplier) || 1) : 1,
username: plugType === 'tasmota' ? (username.trim() || null) : null,
password: plugType === 'tasmota' ? (password.trim() || null) : null,
printer_id: printerId,
controls_printer_power: controlsPrinterPower,
// Power alerts
power_alert_enabled: powerAlertEnabled,
power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,
power_alert_low: powerAlertLow ? parseFloat(powerAlertLow) : null,
// Schedule
schedule_enabled: scheduleEnabled,
schedule_on_time: scheduleOnTime || null,
schedule_off_time: scheduleOffTime || null,
// Visibility
show_in_switchbar: showInSwitchbar,
show_on_printer_card: showOnPrinterCard,
};
if (isEditing) {
updateMutation.mutate(data);
} else {
createMutation.mutate(data);
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<div
className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary flex-shrink-0">
<h2 className="text-lg font-semibold text-white">
{isEditing ? t('smartPlugs.editTitle') : t('smartPlugs.addTitle')}
</h2>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto">
{error && (
<div className="p-3 bg-red-100 dark:bg-red-500/20 border border-red-400 dark:border-red-500/50 rounded-lg text-sm text-red-700 dark:text-red-400">
{error}
</div>
)}
{/* Plug Type Selector - only show when not editing */}
{!isEditing && (
<div className="flex gap-2 mb-2">
<button
type="button"
onClick={() => {
setPlugType('tasmota');
setTestResult(null);
setError(null);
}}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
plugType === 'tasmota'
? 'bg-bambu-green text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
}`}
>
<Plug className="w-4 h-4" />
Tasmota
</button>
<button
type="button"
onClick={() => {
setPlugType('homeassistant');
setTestResult(null);
setError(null);
}}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
plugType === 'homeassistant'
? 'bg-bambu-green text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
}`}
>
<Home className="w-4 h-4" />
HA
</button>
<button
type="button"
onClick={() => {
setPlugType('mqtt');
setTestResult(null);
setError(null);
}}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
plugType === 'mqtt'
? 'bg-bambu-green text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
}`}
>
<Radio className="w-4 h-4" />
MQTT
</button>
<button
type="button"
onClick={() => {
setPlugType('rest');
setTestResult(null);
setError(null);
}}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
plugType === 'rest'
? 'bg-bambu-green text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
}`}
>
<Globe className="w-4 h-4" />
REST
</button>
</div>
)}
{/* Discovery Section - only show when not editing and Tasmota is selected */}
{!isEditing && plugType === 'tasmota' && (
<div className="space-y-3">
{/* Scan button - auto-detects network */}
{isScanning ? (
<Button type="button" variant="secondary" onClick={stopScan} className="w-full">
<X className="w-4 h-4" />
{t('smartPlugs.stopScanning')}
</Button>
) : (
<Button type="button" variant="primary" onClick={startScan} className="w-full">
<Search className="w-4 h-4" />
{t('smartPlugs.discoverTasmota')}
</Button>
)}
{/* Progress bar */}
{isScanning && scanProgress.total > 0 && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-bambu-gray">
<span>{t('smartPlugs.addSmartPlug.scanningNetwork')}</span>
<span>{scanProgress.scanned} / {scanProgress.total}</span>
</div>
<div className="w-full bg-bambu-dark-tertiary rounded-full h-2">
<div
className="bg-bambu-green h-2 rounded-full transition-all duration-300"
style={{ width: `${(scanProgress.scanned / scanProgress.total) * 100}%` }}
/>
</div>
</div>
)}
{/* Discovered devices */}
{discoveredDevices.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-bambu-gray">{t('smartPlugs.foundDevices', { count: discoveredDevices.length })}</p>
<div className="max-h-40 overflow-y-auto space-y-1">
{discoveredDevices.map((device) => (
<button
key={device.ip_address}
type="button"
onClick={() => selectDevice(device)}
className="w-full flex items-center justify-between p-2 bg-bambu-dark hover:bg-bambu-dark-tertiary rounded-lg transition-colors text-left border border-bambu-dark-tertiary"
>
<div className="flex items-center gap-2">
<Plug className="w-4 h-4 text-bambu-green" />
<div>
<p className="text-sm text-white">{device.name}</p>
<p className="text-xs text-bambu-gray">{device.ip_address}</p>
</div>
</div>
{device.state && (
<span className={`flex items-center gap-1 text-xs ${
device.state === 'ON' ? 'text-bambu-green' : 'text-bambu-gray'
}`}>
<Power className="w-3 h-3" />
{device.state}
</span>
)}
</button>
))}
</div>
</div>
)}
{!isScanning && discoveredDevices.length === 0 && scanProgress.total > 0 && (
<p className="text-xs text-bambu-gray text-center py-2">
{t('smartPlugs.noDevicesFound')}
</p>
)}
</div>
)}
{/* Home Assistant Entity Selector - only show when HA is selected */}
{plugType === 'homeassistant' && (
<div className="space-y-3">
{/* HA not configured */}
{!haConfigured && (
<div className="space-y-3">
<div className="p-3 bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-400 dark:border-yellow-500/50 rounded-lg text-sm text-yellow-700 dark:text-yellow-400">
{t('smartPlugs.haNotConfigured')}{' '}
<span className="font-medium">{t('smartPlugs.haSettingsPath')}</span>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1 opacity-50">{t('smartPlugs.selectEntity')}</label>
<select
disabled
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray cursor-not-allowed opacity-50"
>
<option>{t('smartPlugs.addSmartPlug.chooseEntity')}</option>
</select>
</div>
</div>
)}
{/* HA configured - show loading/entities */}
{haConfigured && (
<>
{haEntitiesLoading && (
<div className="flex items-center justify-center py-4 text-bambu-gray">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
{t('smartPlugs.loadingEntities')}
</div>
)}
{haEntitiesError && (
<div className="p-3 bg-red-100 dark:bg-red-500/20 border border-red-400 dark:border-red-500/50 rounded-lg text-sm text-red-700 dark:text-red-400">
{t('smartPlugs.failedToLoadEntities', { error: (haEntitiesError as Error).message })}
</div>
)}
{/* Searchable Entity Dropdown */}
{(() => {
// Filter out entities already configured (except current plug when editing)
const configuredEntityIds = existingPlugs
?.filter(p => p.ha_entity_id && p.id !== plug?.id)
.map(p => p.ha_entity_id) || [];
const availableEntities = (haEntities || []).filter(e => !configuredEntityIds.includes(e.entity_id));
const selectedEntity = haEntities?.find(e => e.entity_id === haEntityId);
return (
<div ref={entityDropdownRef} className="relative">
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.selectEntity')}</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
<input
type="text"
value={isEntityDropdownOpen ? haEntitySearch : (selectedEntity ? `${selectedEntity.friendly_name} (${selectedEntity.entity_id})` : '')}
onChange={(e) => {
setHaEntitySearch(e.target.value);
if (!isEntityDropdownOpen) setIsEntityDropdownOpen(true);
}}
onFocus={() => {
setIsEntityDropdownOpen(true);
setHaEntitySearch('');
}}
placeholder={t('smartPlugs.addSmartPlug.placeholders.searchEntities')}
className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
{haEntityId && !isEntityDropdownOpen && (
<button
type="button"
onClick={() => {
setHaEntityId('');
setHaEntitySearch('');
}}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded"
>
<X className="w-4 h-4 text-bambu-gray hover:text-white" />
</button>
)}
{haEntitiesLoading && (
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray animate-spin" />
)}
</div>
{/* Dropdown */}
{isEntityDropdownOpen && (
<div className="absolute z-50 w-full mt-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-60 overflow-y-auto">
{haEntitiesLoading && (
<div className="px-3 py-2 text-sm text-bambu-gray flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{t('smartPlugs.loading')}
</div>
)}
{!haEntitiesLoading && availableEntities.length === 0 && (
<div className="px-3 py-2 text-sm text-bambu-gray">
{debouncedSearch
? t('smartPlugs.noEntitiesMatching', { search: debouncedSearch })
: t('smartPlugs.noEntitiesAvailable')}
</div>
)}
{!haEntitiesLoading && availableEntities.map((entity) => (
<button
key={entity.entity_id}
type="button"
onClick={() => {
setHaEntityId(entity.entity_id);
setIsEntityDropdownOpen(false);
setHaEntitySearch('');
// Auto-fill name
if (!name) {
setName(entity.friendly_name);
}
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary transition-colors ${
entity.entity_id === haEntityId ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'
}`}
>
<div className="font-medium">{entity.friendly_name}</div>
<div className="text-xs text-bambu-gray flex items-center justify-between">
<span>{entity.entity_id}</span>
<span className={entity.state === 'on' ? 'text-bambu-green' : ''}>{entity.state}</span>
</div>
</button>
))}
</div>
)}
<p className="text-xs text-bambu-gray mt-1">
{debouncedSearch
? t('smartPlugs.searchingEntities', { count: availableEntities.length })
: t('smartPlugs.showingEntities', { count: availableEntities.length })}
</p>
</div>
);
})()}
{/* Energy Monitoring Section (Optional) */}
{haEntityId && haSensorEntities && haSensorEntities.length > 0 && (
<div className="border-t border-bambu-dark-tertiary pt-4 mt-4 space-y-3">
<div>
<p className="text-white font-medium mb-1">{t('smartPlugs.energyMonitoringOptional')}</p>
<p className="text-xs text-bambu-gray mb-3">
{t('smartPlugs.energyMonitoringHint')}
</p>
</div>
{/* Power Sensor (W) */}
{(() => {
const powerSensors = haSensorEntities.filter(s =>
s.unit_of_measurement === 'W' || s.unit_of_measurement === 'kW' || s.unit_of_measurement === 'mW'
);
const filteredPowerSensors = powerSensorSearch
? powerSensors.filter(s =>
s.entity_id.toLowerCase().includes(powerSensorSearch.toLowerCase()) ||
s.friendly_name.toLowerCase().includes(powerSensorSearch.toLowerCase())
)
: powerSensors;
const selectedPowerSensor = haSensorEntities.find(s => s.entity_id === haPowerEntity);
return (
<div ref={powerDropdownRef} className="relative">
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.powerSensorW')}</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
<input
type="text"
value={isPowerDropdownOpen ? powerSensorSearch : (selectedPowerSensor ? `${selectedPowerSensor.friendly_name} (${selectedPowerSensor.state} ${selectedPowerSensor.unit_of_measurement})` : '')}
onChange={(e) => {
setPowerSensorSearch(e.target.value);
if (!isPowerDropdownOpen) setIsPowerDropdownOpen(true);
}}
onFocus={() => {
setIsPowerDropdownOpen(true);
setPowerSensorSearch('');
}}
placeholder={t('smartPlugs.addSmartPlug.placeholders.searchPowerSensors')}
className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
{haPowerEntity && !isPowerDropdownOpen && (
<button
type="button"
onClick={() => {
setHaPowerEntity('');
setPowerSensorSearch('');
}}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded"
>
<X className="w-4 h-4 text-bambu-gray hover:text-white" />
</button>
)}
</div>
{isPowerDropdownOpen && (
<div className="absolute z-50 w-full mt-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto">
<button
type="button"
onClick={() => {
setHaPowerEntity('');
setIsPowerDropdownOpen(false);
setPowerSensorSearch('');
}}
className="w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary"
>
{t('smartPlugs.none')}
</button>
{filteredPowerSensors.map((sensor) => (
<button
key={sensor.entity_id}
type="button"
onClick={() => {
setHaPowerEntity(sensor.entity_id);
setIsPowerDropdownOpen(false);
setPowerSensorSearch('');
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
sensor.entity_id === haPowerEntity ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'
}`}
>
<div className="font-medium">{sensor.friendly_name}</div>
<div className="text-xs text-bambu-gray">{sensor.entity_id} • {sensor.state} {sensor.unit_of_measurement}</div>
</button>
))}
{filteredPowerSensors.length === 0 && (
<div className="px-3 py-2 text-sm text-bambu-gray">{t('smartPlugs.noMatchingSensors')}</div>
)}
</div>
)}
</div>
);
})()}
{/* Energy Today (kWh) */}
{(() => {
const energySensors = haSensorEntities.filter(s =>
s.unit_of_measurement === 'kWh' || s.unit_of_measurement === 'Wh' || s.unit_of_measurement === 'MWh'
);
const filteredEnergySensors = energyTodaySearch
? energySensors.filter(s =>
s.entity_id.toLowerCase().includes(energyTodaySearch.toLowerCase()) ||
s.friendly_name.toLowerCase().includes(energyTodaySearch.toLowerCase())
)
: energySensors;
const selectedSensor = haSensorEntities.find(s => s.entity_id === haEnergyTodayEntity);
return (
<div ref={energyTodayDropdownRef} className="relative">
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.energyTodayKwh')}</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
<input
type="text"
value={isEnergyTodayDropdownOpen ? energyTodaySearch : (selectedSensor ? `${selectedSensor.friendly_name} (${selectedSensor.state} ${selectedSensor.unit_of_measurement})` : '')}
onChange={(e) => {
setEnergyTodaySearch(e.target.value);
if (!isEnergyTodayDropdownOpen) setIsEnergyTodayDropdownOpen(true);
}}
onFocus={() => {
setIsEnergyTodayDropdownOpen(true);
setEnergyTodaySearch('');
}}
placeholder={t('smartPlugs.addSmartPlug.placeholders.searchEnergySensors')}
className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
{haEnergyTodayEntity && !isEnergyTodayDropdownOpen && (
<button
type="button"
onClick={() => {
setHaEnergyTodayEntity('');
setEnergyTodaySearch('');
}}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded"
>
<X className="w-4 h-4 text-bambu-gray hover:text-white" />
</button>
)}
</div>
{isEnergyTodayDropdownOpen && (
<div className="absolute z-50 w-full mt-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto">
<button
type="button"
onClick={() => {
setHaEnergyTodayEntity('');
setIsEnergyTodayDropdownOpen(false);
setEnergyTodaySearch('');
}}
className="w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary"
>
{t('smartPlugs.none')}
</button>
{filteredEnergySensors.map((sensor) => (
<button
key={sensor.entity_id}
type="button"
onClick={() => {
setHaEnergyTodayEntity(sensor.entity_id);
setIsEnergyTodayDropdownOpen(false);
setEnergyTodaySearch('');
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
sensor.entity_id === haEnergyTodayEntity ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'
}`}
>
<div className="font-medium">{sensor.friendly_name}</div>
<div className="text-xs text-bambu-gray">{sensor.entity_id} • {sensor.state} {sensor.unit_of_measurement}</div>
</button>
))}
{filteredEnergySensors.length === 0 && (
<div className="px-3 py-2 text-sm text-bambu-gray">{t('smartPlugs.noMatchingSensors')}</div>
)}
</div>
)}
</div>
);
})()}
{/* Total Energy (kWh) */}
{(() => {
const energySensors = haSensorEntities.filter(s =>
s.unit_of_measurement === 'kWh' || s.unit_of_measurement === 'Wh' || s.unit_of_measurement === 'MWh'
);
const filteredEnergySensors = energyTotalSearch
? energySensors.filter(s =>
s.entity_id.toLowerCase().includes(energyTotalSearch.toLowerCase()) ||
s.friendly_name.toLowerCase().includes(energyTotalSearch.toLowerCase())
)
: energySensors;
const selectedSensor = haSensorEntities.find(s => s.entity_id === haEnergyTotalEntity);
return (
<div ref={energyTotalDropdownRef} className="relative">
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.totalEnergyKwh')}</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
<input
type="text"
value={isEnergyTotalDropdownOpen ? energyTotalSearch : (selectedSensor ? `${selectedSensor.friendly_name} (${selectedSensor.state} ${selectedSensor.unit_of_measurement})` : '')}
onChange={(e) => {
setEnergyTotalSearch(e.target.value);
if (!isEnergyTotalDropdownOpen) setIsEnergyTotalDropdownOpen(true);
}}
onFocus={() => {
setIsEnergyTotalDropdownOpen(true);
setEnergyTotalSearch('');
}}
placeholder={t('smartPlugs.addSmartPlug.placeholders.searchEnergySensors')}
className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
{haEnergyTotalEntity && !isEnergyTotalDropdownOpen && (
<button
type="button"
onClick={() => {
setHaEnergyTotalEntity('');
setEnergyTotalSearch('');
}}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded"
>
<X className="w-4 h-4 text-bambu-gray hover:text-white" />
</button>
)}
</div>
{isEnergyTotalDropdownOpen && (
<div className="absolute z-50 w-full mt-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto">
<button
type="button"
onClick={() => {
setHaEnergyTotalEntity('');
setIsEnergyTotalDropdownOpen(false);
setEnergyTotalSearch('');
}}
className="w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary"
>
{t('smartPlugs.none')}
</button>
{filteredEnergySensors.map((sensor) => (
<button
key={sensor.entity_id}
type="button"
onClick={() => {
setHaEnergyTotalEntity(sensor.entity_id);
setIsEnergyTotalDropdownOpen(false);
setEnergyTotalSearch('');
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
sensor.entity_id === haEnergyTotalEntity ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'
}`}
>
<div className="font-medium">{sensor.friendly_name}</div>
<div className="text-xs text-bambu-gray">{sensor.entity_id} • {sensor.state} {sensor.unit_of_measurement}</div>
</button>
))}
{filteredEnergySensors.length === 0 && (
<div className="px-3 py-2 text-sm text-bambu-gray">{t('smartPlugs.noMatchingSensors')}</div>
)}
</div>
)}
</div>
);
})()}
</div>
)}
</>
)}
</div>
)}
{/* MQTT Configuration - only show when MQTT is selected */}
{plugType === 'mqtt' && (
<div className="space-y-3">
{/* MQTT broker not configured */}
{!settings?.mqtt_broker && (
<div className="p-3 bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-400 dark:border-yellow-500/50 rounded-lg text-sm text-yellow-700 dark:text-yellow-400">
{t('smartPlugs.mqttNotConfigured')}{' '}
<span className="font-medium">{t('smartPlugs.mqttSettingsPath')}</span>
{' '}{t('smartPlugs.mqttNotConfiguredSuffix')}
</div>
)}
{/* MQTT broker configured - show fields */}
{settings?.mqtt_broker && (
<>
<div className="p-3 bg-blue-50 dark:bg-blue-500/10 border border-blue-300 dark:border-blue-500/30 rounded-lg text-sm text-blue-700 dark:text-blue-300">
<p className="font-medium mb-1">{t('smartPlugs.monitorOnly')}</p>
<p className="text-xs opacity-80">
{t('smartPlugs.mqttMonitorOnlyDescription')}
</p>
</div>
{/* Power Section */}
<div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-white font-medium text-sm">{t('smartPlugs.powerMonitoring')}</p>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.topic')}</label>
<input
type="text"
value={mqttPowerTopic}
onChange={(e) => setMqttPowerTopic(e.target.value)}
placeholder="zigbee2mqtt/shelly-working-room"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.jsonPath')}</label>
<input
type="text"
value={mqttPowerPath}
onChange={(e) => setMqttPowerPath(e.target.value)}
placeholder="power_l1"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.multiplier')}</label>
<input
type="text"
value={mqttPowerMultiplier}
onChange={(e) => setMqttPowerMultiplier(e.target.value)}
placeholder="1"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
<p className="text-xs text-bambu-gray" style={{ whiteSpace: 'pre-line' }}>
{t('smartPlugs.mqttPowerHint')}
</p>
</div>
{/* Energy Section */}
<div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-white font-medium text-sm">{t('smartPlugs.energyMonitoring')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></p>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.topic')}</label>
<input
type="text"
value={mqttEnergyTopic}
onChange={(e) => setMqttEnergyTopic(e.target.value)}
placeholder="Same as power topic, or different"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.jsonPath')}</label>
<input
type="text"
value={mqttEnergyPath}
onChange={(e) => setMqttEnergyPath(e.target.value)}
placeholder="energy_l1"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.multiplier')}</label>
<input
type="text"
value={mqttEnergyMultiplier}
onChange={(e) => setMqttEnergyMultiplier(e.target.value)}
placeholder="1"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
<p className="text-xs text-bambu-gray" style={{ whiteSpace: 'pre-line' }}>
{t('smartPlugs.mqttEnergyHint')}
</p>
</div>
{/* State Section */}
<div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-white font-medium text-sm">{t('smartPlugs.stateMonitoring')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></p>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.topic')}</label>
<input
type="text"
value={mqttStateTopic}
onChange={(e) => setMqttStateTopic(e.target.value)}
placeholder="Same as power topic, or different"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.jsonPath')}</label>
<input
type="text"
value={mqttStatePath}
onChange={(e) => setMqttStatePath(e.target.value)}
placeholder="state_l1"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.onValue')}</label>
<input
type="text"
value={mqttStateOnValue}
onChange={(e) => setMqttStateOnValue(e.target.value)}
placeholder={t('smartPlugs.addSmartPlug.placeholders.mqttStateOnValue')}
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
<p className="text-xs text-bambu-gray" style={{ whiteSpace: 'pre-line' }}>
{t('smartPlugs.mqttStateHint')}
</p>
</div>
</>
)}
</div>
)}
{/* REST API Section */}
{plugType === 'rest' && (
<div className="space-y-3">
{/* Control Section */}
<div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-white font-medium text-sm">{t('smartPlugs.restControl')}</p>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restMethod')}</label>
<select
value={restMethod}
onChange={(e) => setRestMethod(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
</select>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restOnUrl')}</label>
<input
type="text"
value={restOnUrl}
onChange={(e) => { setRestOnUrl(e.target.value); setTestResult(null); }}
placeholder="http://openhab:8080/rest/items/MyPlug"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restOnBody')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></label>
<input
type="text"
value={restOnBody}
onChange={(e) => setRestOnBody(e.target.value)}
placeholder={t('smartPlugs.restBodyHint')}
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restOffUrl')}</label>
<input
type="text"
value={restOffUrl}
onChange={(e) => { setRestOffUrl(e.target.value); setTestResult(null); }}
placeholder="http://openhab:8080/rest/items/MyPlug"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restOffBody')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></label>
<input
type="text"
value={restOffBody}
onChange={(e) => setRestOffBody(e.target.value)}
placeholder={t('smartPlugs.restBodyHint')}
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
{/* Headers Section */}
<div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-white font-medium text-sm">{t('smartPlugs.restHeaders')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></p>
<div>
<textarea
value={restHeaders}
onChange={(e) => setRestHeaders(e.target.value)}
placeholder={t('smartPlugs.restHeadersHint')}
rows={2}
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none font-mono text-sm"
/>
</div>
</div>
{/* Status Polling Section (optional) */}
<div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-white font-medium text-sm">{t('smartPlugs.stateMonitoring')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></p>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restStatusUrl')}</label>
<input
type="text"
value={restStatusUrl}
onChange={(e) => setRestStatusUrl(e.target.value)}
placeholder={t('smartPlugs.restStatusHint')}
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restStatusPath')}</label>
<input
type="text"
value={restStatusPath}
onChange={(e) => setRestStatusPath(e.target.value)}
placeholder={t('smartPlugs.restPathHint')}
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restStatusOnValue')}</label>
<input
type="text"
value={restStatusOnValue}
onChange={(e) => setRestStatusOnValue(e.target.value)}
placeholder="ON"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
</div>
{/* Energy Monitoring (optional) */}
<div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
<p className="text-white font-medium text-sm">{t('smartPlugs.energyMonitoring')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></p>
{/* Power */}
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restPowerUrl')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></label>
<input
type="text"
value={restPowerUrl}
onChange={(e) => setRestPowerUrl(e.target.value)}
placeholder={t('smartPlugs.restPowerUrlHint')}
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restPowerPath')}</label>
<input
type="text"
value={restPowerPath}
onChange={(e) => setRestPowerPath(e.target.value)}
placeholder={t('smartPlugs.restPathHint')}
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restPowerMultiplier')}</label>
<input
type="text"
value={restPowerMultiplier}
onChange={(e) => setRestPowerMultiplier(e.target.value)}
placeholder="1"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
{/* Energy */}
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restEnergyUrl')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></label>
<input
type="text"
value={restEnergyUrl}
onChange={(e) => setRestEnergyUrl(e.target.value)}
placeholder={t('smartPlugs.restEnergyUrlHint')}
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restEnergyPath')}</label>
<input
type="text"
value={restEnergyPath}
onChange={(e) => setRestEnergyPath(e.target.value)}
placeholder={t('smartPlugs.restPathHint')}
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restEnergyMultiplier')}</label>
<input
type="text"
value={restEnergyMultiplier}
onChange={(e) => setRestEnergyMultiplier(e.target.value)}
placeholder="1"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
<p className="text-xs text-bambu-gray">
{t('smartPlugs.restEnergyHint')}
</p>
{/* Lifetime counter (#2539) — the only energy figure a Shelly has. */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restEnergyTotalPath')}</label>
<input
type="text"
value={restEnergyTotalPath}
onChange={(e) => setRestEnergyTotalPath(e.target.value)}
placeholder={t('smartPlugs.restEnergyTotalPathHint')}
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restEnergyTotalMultiplier')}</label>
<input
type="text"
value={restEnergyTotalMultiplier}
onChange={(e) => setRestEnergyTotalMultiplier(e.target.value)}
placeholder="1"
className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
<p className="text-xs text-bambu-gray">
{t('smartPlugs.restEnergyTotalHint')}
</p>
</div>
{/* Test Connection */}
{(restOnUrl.trim() || restOffUrl.trim()) && (
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
onClick={async () => {
setTestResult(null);
try {
const url = restOnUrl.trim() || restOffUrl.trim();
const result = await api.testRESTConnection(url, restMethod, restHeaders.trim() || null);
setTestResult({ success: result.success });
if (!result.success) {
setError(result.error || t('smartPlugs.addSmartPlug.connectionFailed'));
}
} catch {
setTestResult({ success: false });
setError(t('smartPlugs.addSmartPlug.connectionFailed'));
}
}}
className="w-full"
>
<Wifi className="w-4 h-4" />
{t('smartPlugs.testConnection')}
</Button>
</div>
)}
{testResult && (
<div className={`flex items-center gap-2 text-sm ${testResult.success ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}`}>
{testResult.success ? <CheckCircle className="w-4 h-4" /> : <WifiOff className="w-4 h-4" />}
{testResult.success ? t('smartPlugs.connectionSuccess') : t('smartPlugs.addSmartPlug.connectionFailed')}
</div>
)}
</div>
)}
{/* IP Address - only show for Tasmota */}
{plugType === 'tasmota' && (
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.ipAddress')}</label>
<div className="flex gap-2">
<input
type="text"
value={ipAddress}
onChange={(e) => {
setIpAddress(e.target.value);
setTestResult(null);
}}
placeholder="192.168.1.100"
className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
<Button
type="button"
variant="secondary"
onClick={() => testMutation.mutate()}
disabled={!ipAddress.trim() || testMutation.isPending}
>
{testMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Wifi className="w-4 h-4" />
)}
{t('smartPlugs.test')}
</Button>
</div>
</div>
)}
{/* Test Result - only show for Tasmota */}
{plugType === 'tasmota' && testResult && (
<div className={`p-3 rounded-lg flex items-center gap-2 ${
testResult.success
? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'
: 'bg-red-100 dark:bg-red-500/20 border border-red-400 dark:border-red-500/50 text-red-700 dark:text-red-400'
}`}>
{testResult.success ? (
<>
<CheckCircle className="w-5 h-5" />
<div>
<p className="font-medium">{t('smartPlugs.connectedResult')}</p>
<p className="text-sm opacity-80">
{testResult.device_name && t('smartPlugs.deviceLabel', { name: testResult.device_name })}
{t('smartPlugs.stateLabel', { state: testResult.state })}
</p>
</div>
</>
) : (
<>
<WifiOff className="w-5 h-5" />
<span>{t('smartPlugs.addSmartPlug.connectionFailed')}</span>
</>
)}
</div>
)}
{/* Name */}
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.nameLabel')}</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('smartPlugs.addSmartPlug.placeholders.plugName')}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
{/* Authentication (optional) - only show for Tasmota */}
{plugType === 'tasmota' && (
<>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.username')}</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="admin"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.password')}</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="********"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
<p className="text-xs text-bambu-gray -mt-2">
{t('smartPlugs.authHint')}
</p>
</>
)}
{/* Link to Printer - not shown for MQTT plugs (monitor-only) */}
{plugType !== 'mqtt' && (
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.linkToPrinter')}</label>
<select
value={printerId ?? ''}
onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="">{t('smartPlugs.noPrinter')}</option>
{availablePrinters?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
{t('smartPlugs.linkingDescription')}
</p>
{/* Whether the plug feeds the printer itself, or is an accessory
that merely follows the print cycle (#2629). */}
{printerId !== null && (
<div className="flex items-center justify-between mt-3">
<div className="pr-3">
<p className="text-sm text-white">{t('smartPlugs.controlsPrinterPower')}</p>
<p className="text-xs text-bambu-gray">{t('smartPlugs.controlsPrinterPowerDescription')}</p>
</div>
<label className="relative inline-flex items-center cursor-pointer shrink-0">
<input
type="checkbox"
checked={controlsPrinterPower}
onChange={(e) => setControlsPrinterPower(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>
)}
{/* Power Alerts */}
<div className="border-t border-bambu-dark-tertiary pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Bell className="w-4 h-4 text-bambu-green" />
<span className="text-white font-medium">{t('smartPlugs.powerAlerts')}</span>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={powerAlertEnabled}
onChange={(e) => setPowerAlertEnabled(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>
{powerAlertEnabled && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.alertAbove')}</label>
<input
type="number"
value={powerAlertHigh}
onChange={(e) => setPowerAlertHigh(e.target.value)}
placeholder="e.g. 200"
min="0"
max="5000"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.alertBelow')}</label>
<input
type="number"
value={powerAlertLow}
onChange={(e) => setPowerAlertLow(e.target.value)}
placeholder="e.g. 10"
min="0"
max="5000"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
<p className="text-xs text-bambu-gray">
{t('smartPlugs.alertDescription')}
</p>
</div>
)}
</div>
{/* Schedule - not shown for MQTT plugs (monitor-only) */}
{plugType !== 'mqtt' && (
<div className="border-t border-bambu-dark-tertiary pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-bambu-green" />
<span className="text-white font-medium">{t('smartPlugs.dailySchedule')}</span>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={scheduleEnabled}
onChange={(e) => setScheduleEnabled(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>
{scheduleEnabled && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.turnOnAt')}</label>
<input
type="time"
value={scheduleOnTime}
onChange={(e) => setScheduleOnTime(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.turnOffAt')}</label>
<input
type="time"
value={scheduleOffTime}
onChange={(e) => setScheduleOffTime(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
<p className="text-xs text-bambu-gray">
{t('smartPlugs.scheduleDescription')}
</p>
</div>
)}
</div>
)}
{/* Switchbar Visibility */}
<div className="border-t border-bambu-dark-tertiary pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<LayoutGrid className="w-4 h-4 text-bambu-green" />
<div>
<span className="text-white font-medium">{t('smartPlugs.showInSwitchbar')}</span>
<p className="text-xs text-bambu-gray">{t('smartPlugs.quickAccessSidebar')}</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={showInSwitchbar}
onChange={(e) => setShowInSwitchbar(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>
{/* 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">{t('smartPlugs.showOnPrinterCard')}</span>
<p className="text-xs text-bambu-gray">{t('smartPlugs.displayOnPrinterCard')}</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
type="button"
variant="secondary"
onClick={onClose}
className="flex-1"
>
{t('smartPlugs.cancel')}
</Button>
<Button
type="submit"
disabled={isPending}
className="flex-1"
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
{isEditing ? t('smartPlugs.save') : t('smartPlugs.add')}
</Button>
</div>
</form>
</div>
</div>
);
}