mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-10 07:55:39 +02:00
REST/Webhook smart plugs can now fetch power and energy data from individual URLs instead of requiring all values in a single status response. Each value falls back to the shared Status URL when no separate URL is set, preserving backward compatibility. Added power and energy multipliers for unit conversion (e.g. 0.001 for Wh→kWh).
1655 lines
84 KiB
TypeScript
1655 lines
84 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());
|
|
// 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);
|
|
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,
|
|
username: plugType === 'tasmota' ? (username.trim() || null) : null,
|
|
password: plugType === 'tasmota' ? (password.trim() || null) : null,
|
|
printer_id: printerId,
|
|
// 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-500/20 border border-red-500/50 rounded-lg text-sm 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-500/20 border border-yellow-500/50 rounded-lg text-sm 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-500/20 border border-red-500/50 rounded-lg text-sm 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-500/20 border border-yellow-500/50 rounded-lg text-sm 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-500/10 border border-blue-500/30 rounded-lg text-sm 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>
|
|
</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-400' : '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-500/20 border border-red-500/50 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>
|
|
</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>
|
|
);
|
|
}
|