AddSmartPlugModal.tsx 88 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708
  1. import { useState, useEffect, useRef } from 'react';
  2. import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
  3. import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Radio, Eye, Globe } from 'lucide-react';
  4. import { useTranslation } from 'react-i18next';
  5. import { api } from '../api/client';
  6. import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
  7. import { Button } from './Button';
  8. interface AddSmartPlugModalProps {
  9. plug?: SmartPlug | null;
  10. onClose: () => void;
  11. }
  12. export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
  13. const { t } = useTranslation();
  14. const queryClient = useQueryClient();
  15. const isEditing = !!plug;
  16. // Plug type selection
  17. const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant' | 'mqtt' | 'rest'>(plug?.plug_type || 'tasmota');
  18. const [name, setName] = useState(plug?.name || '');
  19. // Tasmota fields
  20. const [ipAddress, setIpAddress] = useState(plug?.ip_address || '');
  21. const [username, setUsername] = useState(plug?.username || '');
  22. const [password, setPassword] = useState(plug?.password || '');
  23. // Home Assistant fields
  24. const [haEntityId, setHaEntityId] = useState(plug?.ha_entity_id || '');
  25. // MQTT fields - Power
  26. const [mqttPowerTopic, setMqttPowerTopic] = useState(plug?.mqtt_power_topic || plug?.mqtt_topic || '');
  27. const [mqttPowerPath, setMqttPowerPath] = useState(plug?.mqtt_power_path || '');
  28. const [mqttPowerMultiplier, setMqttPowerMultiplier] = useState<string>(
  29. (plug?.mqtt_power_multiplier ?? plug?.mqtt_multiplier ?? 1).toString()
  30. );
  31. // MQTT fields - Energy
  32. const [mqttEnergyTopic, setMqttEnergyTopic] = useState(plug?.mqtt_energy_topic || '');
  33. const [mqttEnergyPath, setMqttEnergyPath] = useState(plug?.mqtt_energy_path || '');
  34. const [mqttEnergyMultiplier, setMqttEnergyMultiplier] = useState<string>(
  35. (plug?.mqtt_energy_multiplier ?? 1).toString()
  36. );
  37. // MQTT fields - State
  38. const [mqttStateTopic, setMqttStateTopic] = useState(plug?.mqtt_state_topic || '');
  39. const [mqttStatePath, setMqttStatePath] = useState(plug?.mqtt_state_path || '');
  40. const [mqttStateOnValue, setMqttStateOnValue] = useState(plug?.mqtt_state_on_value || '');
  41. // REST fields
  42. const [restOnUrl, setRestOnUrl] = useState(plug?.rest_on_url || '');
  43. const [restOnBody, setRestOnBody] = useState(plug?.rest_on_body || '');
  44. const [restOffUrl, setRestOffUrl] = useState(plug?.rest_off_url || '');
  45. const [restOffBody, setRestOffBody] = useState(plug?.rest_off_body || '');
  46. const [restMethod, setRestMethod] = useState(plug?.rest_method || 'POST');
  47. const [restHeaders, setRestHeaders] = useState(plug?.rest_headers || '');
  48. const [restStatusUrl, setRestStatusUrl] = useState(plug?.rest_status_url || '');
  49. const [restStatusPath, setRestStatusPath] = useState(plug?.rest_status_path || '');
  50. const [restStatusOnValue, setRestStatusOnValue] = useState(plug?.rest_status_on_value || '');
  51. const [restPowerUrl, setRestPowerUrl] = useState(plug?.rest_power_url || '');
  52. const [restPowerPath, setRestPowerPath] = useState(plug?.rest_power_path || '');
  53. const [restPowerMultiplier, setRestPowerMultiplier] = useState<string>((plug?.rest_power_multiplier ?? 1).toString());
  54. const [restEnergyUrl, setRestEnergyUrl] = useState(plug?.rest_energy_url || '');
  55. const [restEnergyPath, setRestEnergyPath] = useState(plug?.rest_energy_path || '');
  56. const [restEnergyMultiplier, setRestEnergyMultiplier] = useState<string>((plug?.rest_energy_multiplier ?? 1).toString());
  57. const [restEnergyTotalPath, setRestEnergyTotalPath] = useState(plug?.rest_energy_total_path || '');
  58. const [restEnergyTotalMultiplier, setRestEnergyTotalMultiplier] = useState<string>((plug?.rest_energy_total_multiplier ?? 1).toString());
  59. // HA energy sensor entities (optional)
  60. const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || '');
  61. const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_entity || '');
  62. const [haEnergyTotalEntity, setHaEnergyTotalEntity] = useState(plug?.ha_energy_total_entity || '');
  63. // HA entity search
  64. const [haEntitySearch, setHaEntitySearch] = useState('');
  65. const [debouncedSearch, setDebouncedSearch] = useState('');
  66. const [isEntityDropdownOpen, setIsEntityDropdownOpen] = useState(false);
  67. const entityDropdownRef = useRef<HTMLDivElement>(null);
  68. // Energy sensor search states
  69. const [powerSensorSearch, setPowerSensorSearch] = useState('');
  70. const [isPowerDropdownOpen, setIsPowerDropdownOpen] = useState(false);
  71. const powerDropdownRef = useRef<HTMLDivElement>(null);
  72. const [energyTodaySearch, setEnergyTodaySearch] = useState('');
  73. const [isEnergyTodayDropdownOpen, setIsEnergyTodayDropdownOpen] = useState(false);
  74. const energyTodayDropdownRef = useRef<HTMLDivElement>(null);
  75. const [energyTotalSearch, setEnergyTotalSearch] = useState('');
  76. const [isEnergyTotalDropdownOpen, setIsEnergyTotalDropdownOpen] = useState(false);
  77. const energyTotalDropdownRef = useRef<HTMLDivElement>(null);
  78. const [printerId, setPrinterId] = useState<number | null>(plug?.printer_id || null);
  79. // #2629: defaults to true (a plug linked to a printer usually powers it);
  80. // users turn it off for accessories like a filter fan or chamber light.
  81. const [controlsPrinterPower, setControlsPrinterPower] = useState(plug?.controls_printer_power ?? true);
  82. const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
  83. const [error, setError] = useState<string | null>(null);
  84. // Power alert settings
  85. const [powerAlertEnabled, setPowerAlertEnabled] = useState(plug?.power_alert_enabled || false);
  86. const [powerAlertHigh, setPowerAlertHigh] = useState<string>(plug?.power_alert_high?.toString() || '');
  87. const [powerAlertLow, setPowerAlertLow] = useState<string>(plug?.power_alert_low?.toString() || '');
  88. // Schedule settings
  89. const [scheduleEnabled, setScheduleEnabled] = useState(plug?.schedule_enabled || false);
  90. const [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_time || '');
  91. const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
  92. // Visibility options
  93. const [showInSwitchbar, setShowInSwitchbar] = useState(plug?.show_in_switchbar || false);
  94. const [showOnPrinterCard, setShowOnPrinterCard] = useState(plug?.show_on_printer_card ?? true);
  95. // Discovery state
  96. const [isScanning, setIsScanning] = useState(false);
  97. const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 });
  98. const [discoveredDevices, setDiscoveredDevices] = useState<DiscoveredTasmotaDevice[]>([]);
  99. const scanPollRef = useRef<NodeJS.Timeout | null>(null);
  100. // Fetch printers for linking
  101. const { data: printers } = useQuery({
  102. queryKey: ['printers'],
  103. queryFn: api.getPrinters,
  104. });
  105. // Fetch existing plugs to check for conflicts
  106. const { data: existingPlugs } = useQuery({
  107. queryKey: ['smart-plugs'],
  108. queryFn: api.getSmartPlugs,
  109. });
  110. // Fetch settings to check if HA is configured
  111. const { data: settings } = useQuery({
  112. queryKey: ['settings'],
  113. queryFn: api.getSettings,
  114. });
  115. // Check if HA is properly configured
  116. const haConfigured = !!(settings?.ha_enabled && settings?.ha_url && settings?.ha_token);
  117. // Debounce search input
  118. useEffect(() => {
  119. const timer = setTimeout(() => {
  120. setDebouncedSearch(haEntitySearch);
  121. }, 300);
  122. return () => clearTimeout(timer);
  123. }, [haEntitySearch]);
  124. // Close dropdowns when clicking outside
  125. useEffect(() => {
  126. const handleClickOutside = (e: MouseEvent) => {
  127. if (entityDropdownRef.current && !entityDropdownRef.current.contains(e.target as Node)) {
  128. setIsEntityDropdownOpen(false);
  129. }
  130. if (powerDropdownRef.current && !powerDropdownRef.current.contains(e.target as Node)) {
  131. setIsPowerDropdownOpen(false);
  132. }
  133. if (energyTodayDropdownRef.current && !energyTodayDropdownRef.current.contains(e.target as Node)) {
  134. setIsEnergyTodayDropdownOpen(false);
  135. }
  136. if (energyTotalDropdownRef.current && !energyTotalDropdownRef.current.contains(e.target as Node)) {
  137. setIsEnergyTotalDropdownOpen(false);
  138. }
  139. };
  140. document.addEventListener('mousedown', handleClickOutside);
  141. return () => document.removeEventListener('mousedown', handleClickOutside);
  142. }, []);
  143. // Fetch Home Assistant entities when in HA mode AND HA is configured
  144. const { data: haEntities, isLoading: haEntitiesLoading, error: haEntitiesError } = useQuery({
  145. queryKey: ['ha-entities', debouncedSearch],
  146. queryFn: () => api.getHAEntities(debouncedSearch || undefined),
  147. enabled: plugType === 'homeassistant' && haConfigured,
  148. retry: false,
  149. staleTime: 0,
  150. });
  151. // Fetch Home Assistant sensor entities for energy monitoring
  152. const { data: haSensorEntities } = useQuery({
  153. queryKey: ['ha-sensor-entities'],
  154. queryFn: api.getHASensorEntities,
  155. enabled: plugType === 'homeassistant' && haConfigured,
  156. retry: false,
  157. staleTime: 0,
  158. });
  159. // Close on Escape key and cleanup scan polling
  160. useEffect(() => {
  161. const handleKeyDown = (e: KeyboardEvent) => {
  162. if (e.key === 'Escape') onClose();
  163. };
  164. window.addEventListener('keydown', handleKeyDown);
  165. return () => {
  166. window.removeEventListener('keydown', handleKeyDown);
  167. if (scanPollRef.current) {
  168. clearInterval(scanPollRef.current);
  169. }
  170. };
  171. }, [onClose]);
  172. // Start scanning for Tasmota devices (auto-detects network)
  173. const startScan = async () => {
  174. setIsScanning(true);
  175. setDiscoveredDevices([]);
  176. setScanProgress({ scanned: 0, total: 0 });
  177. setError(null);
  178. try {
  179. await api.startTasmotaScan();
  180. // Poll function to fetch status and devices
  181. const pollStatus = async () => {
  182. try {
  183. const status = await api.getTasmotaScanStatus();
  184. setScanProgress({ scanned: status.scanned, total: status.total });
  185. const devices = await api.getDiscoveredTasmotaDevices();
  186. setDiscoveredDevices(devices);
  187. if (!status.running) {
  188. setIsScanning(false);
  189. if (scanPollRef.current) {
  190. clearInterval(scanPollRef.current);
  191. scanPollRef.current = null;
  192. }
  193. }
  194. } catch (e) {
  195. console.error('Polling error:', e);
  196. }
  197. };
  198. // Poll immediately, then every 500ms
  199. await pollStatus();
  200. scanPollRef.current = setInterval(pollStatus, 500);
  201. } catch (err) {
  202. setIsScanning(false);
  203. const errorMsg = err instanceof Error ? err.message : (typeof err === 'string' ? err : JSON.stringify(err));
  204. setError(errorMsg || t('smartPlugs.failedToStartScan'));
  205. }
  206. };
  207. // Stop scanning
  208. const stopScan = async () => {
  209. try {
  210. await api.stopTasmotaScan();
  211. } catch {
  212. // Ignore stop errors
  213. }
  214. setIsScanning(false);
  215. if (scanPollRef.current) {
  216. clearInterval(scanPollRef.current);
  217. scanPollRef.current = null;
  218. }
  219. };
  220. // Select a discovered device
  221. const selectDevice = (device: DiscoveredTasmotaDevice) => {
  222. setIpAddress(device.ip_address);
  223. setName(device.name);
  224. setTestResult(null);
  225. };
  226. // Test connection mutation
  227. const testMutation = useMutation({
  228. mutationFn: () => api.testSmartPlugConnection(ipAddress, username || null, password || null),
  229. onSuccess: (result) => {
  230. setTestResult(result);
  231. setError(null);
  232. // Auto-fill name from device if empty
  233. if (!name && result.device_name) {
  234. setName(result.device_name);
  235. }
  236. },
  237. onError: (err: Error) => {
  238. setTestResult(null);
  239. setError(err.message);
  240. },
  241. });
  242. // Create mutation
  243. const createMutation = useMutation({
  244. mutationFn: (data: SmartPlugCreate) => api.createSmartPlug(data),
  245. onSuccess: () => {
  246. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  247. // Also invalidate printer card HA entity queries
  248. queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter'] });
  249. onClose();
  250. },
  251. onError: (err: Error) => {
  252. setError(err.message);
  253. },
  254. });
  255. // Update mutation
  256. const updateMutation = useMutation({
  257. mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug!.id, data),
  258. onSuccess: () => {
  259. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  260. // Also invalidate printer card HA entity queries
  261. queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter'] });
  262. onClose();
  263. },
  264. onError: (err: Error) => {
  265. setError(err.message);
  266. },
  267. });
  268. // For Tasmota plugs, only one per printer (physical device)
  269. // For HA scripts, allow multiple per printer
  270. const availablePrinters = printers?.filter(p => {
  271. if (plugType === 'tasmota') {
  272. const hasTasmotaPlug = existingPlugs?.some(
  273. ep => ep.printer_id === p.id && ep.id !== plug?.id && ep.plug_type === 'tasmota'
  274. );
  275. return !hasTasmotaPlug;
  276. }
  277. // HA scripts can have multiple per printer
  278. return true;
  279. });
  280. const handleSubmit = (e: React.FormEvent) => {
  281. e.preventDefault();
  282. setError(null);
  283. if (!name.trim()) {
  284. setError(t('smartPlugs.nameRequired'));
  285. return;
  286. }
  287. if (plugType === 'tasmota' && !ipAddress.trim()) {
  288. setError('IP address is required for Tasmota plugs');
  289. return;
  290. }
  291. if (plugType === 'homeassistant' && !haEntityId) {
  292. setError(t('smartPlugs.entityRequired'));
  293. return;
  294. }
  295. if (plugType === 'mqtt') {
  296. // Check that at least one topic is configured (path is optional)
  297. const hasPower = mqttPowerTopic.trim();
  298. const hasEnergy = mqttEnergyTopic.trim();
  299. const hasState = mqttStateTopic.trim();
  300. if (!hasPower && !hasEnergy && !hasState) {
  301. setError(t('smartPlugs.mqttTopicRequired'));
  302. return;
  303. }
  304. }
  305. if (plugType === 'rest') {
  306. if (!restOnUrl.trim() && !restOffUrl.trim()) {
  307. setError(t('smartPlugs.restUrlRequired'));
  308. return;
  309. }
  310. }
  311. const data = {
  312. name: name.trim(),
  313. plug_type: plugType,
  314. ip_address: plugType === 'tasmota' ? ipAddress.trim() : null,
  315. ha_entity_id: plugType === 'homeassistant' ? haEntityId : null,
  316. // HA energy sensor entities (optional)
  317. ha_power_entity: plugType === 'homeassistant' ? (haPowerEntity || null) : null,
  318. ha_energy_today_entity: plugType === 'homeassistant' ? (haEnergyTodayEntity || null) : null,
  319. ha_energy_total_entity: plugType === 'homeassistant' ? (haEnergyTotalEntity || null) : null,
  320. // MQTT power fields
  321. mqtt_power_topic: plugType === 'mqtt' ? (mqttPowerTopic.trim() || null) : null,
  322. mqtt_power_path: plugType === 'mqtt' ? (mqttPowerPath.trim() || null) : null,
  323. mqtt_power_multiplier: plugType === 'mqtt' ? (parseFloat(mqttPowerMultiplier) || 1) : 1,
  324. // MQTT energy fields
  325. mqtt_energy_topic: plugType === 'mqtt' ? (mqttEnergyTopic.trim() || null) : null,
  326. mqtt_energy_path: plugType === 'mqtt' ? (mqttEnergyPath.trim() || null) : null,
  327. mqtt_energy_multiplier: plugType === 'mqtt' ? (parseFloat(mqttEnergyMultiplier) || 1) : 1,
  328. // MQTT state fields
  329. mqtt_state_topic: plugType === 'mqtt' ? (mqttStateTopic.trim() || null) : null,
  330. mqtt_state_path: plugType === 'mqtt' ? (mqttStatePath.trim() || null) : null,
  331. mqtt_state_on_value: plugType === 'mqtt' ? (mqttStateOnValue.trim() || null) : null,
  332. // REST fields
  333. rest_on_url: plugType === 'rest' ? (restOnUrl.trim() || null) : null,
  334. rest_on_body: plugType === 'rest' ? (restOnBody.trim() || null) : null,
  335. rest_off_url: plugType === 'rest' ? (restOffUrl.trim() || null) : null,
  336. rest_off_body: plugType === 'rest' ? (restOffBody.trim() || null) : null,
  337. rest_method: plugType === 'rest' ? restMethod : null,
  338. rest_headers: plugType === 'rest' ? (restHeaders.trim() || null) : null,
  339. rest_status_url: plugType === 'rest' ? (restStatusUrl.trim() || null) : null,
  340. rest_status_path: plugType === 'rest' ? (restStatusPath.trim() || null) : null,
  341. rest_status_on_value: plugType === 'rest' ? (restStatusOnValue.trim() || null) : null,
  342. rest_power_url: plugType === 'rest' ? (restPowerUrl.trim() || null) : null,
  343. rest_power_path: plugType === 'rest' ? (restPowerPath.trim() || null) : null,
  344. rest_power_multiplier: plugType === 'rest' ? (parseFloat(restPowerMultiplier) || 1) : 1,
  345. rest_energy_url: plugType === 'rest' ? (restEnergyUrl.trim() || null) : null,
  346. rest_energy_path: plugType === 'rest' ? (restEnergyPath.trim() || null) : null,
  347. rest_energy_multiplier: plugType === 'rest' ? (parseFloat(restEnergyMultiplier) || 1) : 1,
  348. rest_energy_total_path: plugType === 'rest' ? (restEnergyTotalPath.trim() || null) : null,
  349. rest_energy_total_multiplier: plugType === 'rest' ? (parseFloat(restEnergyTotalMultiplier) || 1) : 1,
  350. username: plugType === 'tasmota' ? (username.trim() || null) : null,
  351. password: plugType === 'tasmota' ? (password.trim() || null) : null,
  352. printer_id: printerId,
  353. controls_printer_power: controlsPrinterPower,
  354. // Power alerts
  355. power_alert_enabled: powerAlertEnabled,
  356. power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,
  357. power_alert_low: powerAlertLow ? parseFloat(powerAlertLow) : null,
  358. // Schedule
  359. schedule_enabled: scheduleEnabled,
  360. schedule_on_time: scheduleOnTime || null,
  361. schedule_off_time: scheduleOffTime || null,
  362. // Visibility
  363. show_in_switchbar: showInSwitchbar,
  364. show_on_printer_card: showOnPrinterCard,
  365. };
  366. if (isEditing) {
  367. updateMutation.mutate(data);
  368. } else {
  369. createMutation.mutate(data);
  370. }
  371. };
  372. const isPending = createMutation.isPending || updateMutation.isPending;
  373. return (
  374. <div
  375. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  376. onClick={onClose}
  377. >
  378. <div
  379. className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md max-h-[90vh] flex flex-col"
  380. onClick={(e) => e.stopPropagation()}
  381. >
  382. {/* Header */}
  383. <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary flex-shrink-0">
  384. <h2 className="text-lg font-semibold text-white">
  385. {isEditing ? t('smartPlugs.editTitle') : t('smartPlugs.addTitle')}
  386. </h2>
  387. <button
  388. onClick={onClose}
  389. className="text-bambu-gray hover:text-white transition-colors"
  390. >
  391. <X className="w-5 h-5" />
  392. </button>
  393. </div>
  394. {/* Form */}
  395. <form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto">
  396. {error && (
  397. <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">
  398. {error}
  399. </div>
  400. )}
  401. {/* Plug Type Selector - only show when not editing */}
  402. {!isEditing && (
  403. <div className="flex gap-2 mb-2">
  404. <button
  405. type="button"
  406. onClick={() => {
  407. setPlugType('tasmota');
  408. setTestResult(null);
  409. setError(null);
  410. }}
  411. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
  412. plugType === 'tasmota'
  413. ? 'bg-bambu-green text-white'
  414. : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
  415. }`}
  416. >
  417. <Plug className="w-4 h-4" />
  418. Tasmota
  419. </button>
  420. <button
  421. type="button"
  422. onClick={() => {
  423. setPlugType('homeassistant');
  424. setTestResult(null);
  425. setError(null);
  426. }}
  427. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
  428. plugType === 'homeassistant'
  429. ? 'bg-bambu-green text-white'
  430. : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
  431. }`}
  432. >
  433. <Home className="w-4 h-4" />
  434. HA
  435. </button>
  436. <button
  437. type="button"
  438. onClick={() => {
  439. setPlugType('mqtt');
  440. setTestResult(null);
  441. setError(null);
  442. }}
  443. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
  444. plugType === 'mqtt'
  445. ? 'bg-bambu-green text-white'
  446. : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
  447. }`}
  448. >
  449. <Radio className="w-4 h-4" />
  450. MQTT
  451. </button>
  452. <button
  453. type="button"
  454. onClick={() => {
  455. setPlugType('rest');
  456. setTestResult(null);
  457. setError(null);
  458. }}
  459. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
  460. plugType === 'rest'
  461. ? 'bg-bambu-green text-white'
  462. : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
  463. }`}
  464. >
  465. <Globe className="w-4 h-4" />
  466. REST
  467. </button>
  468. </div>
  469. )}
  470. {/* Discovery Section - only show when not editing and Tasmota is selected */}
  471. {!isEditing && plugType === 'tasmota' && (
  472. <div className="space-y-3">
  473. {/* Scan button - auto-detects network */}
  474. {isScanning ? (
  475. <Button type="button" variant="secondary" onClick={stopScan} className="w-full">
  476. <X className="w-4 h-4" />
  477. {t('smartPlugs.stopScanning')}
  478. </Button>
  479. ) : (
  480. <Button type="button" variant="primary" onClick={startScan} className="w-full">
  481. <Search className="w-4 h-4" />
  482. {t('smartPlugs.discoverTasmota')}
  483. </Button>
  484. )}
  485. {/* Progress bar */}
  486. {isScanning && scanProgress.total > 0 && (
  487. <div className="space-y-1">
  488. <div className="flex justify-between text-xs text-bambu-gray">
  489. <span>{t('smartPlugs.addSmartPlug.scanningNetwork')}</span>
  490. <span>{scanProgress.scanned} / {scanProgress.total}</span>
  491. </div>
  492. <div className="w-full bg-bambu-dark-tertiary rounded-full h-2">
  493. <div
  494. className="bg-bambu-green h-2 rounded-full transition-all duration-300"
  495. style={{ width: `${(scanProgress.scanned / scanProgress.total) * 100}%` }}
  496. />
  497. </div>
  498. </div>
  499. )}
  500. {/* Discovered devices */}
  501. {discoveredDevices.length > 0 && (
  502. <div className="space-y-2">
  503. <p className="text-xs text-bambu-gray">{t('smartPlugs.foundDevices', { count: discoveredDevices.length })}</p>
  504. <div className="max-h-40 overflow-y-auto space-y-1">
  505. {discoveredDevices.map((device) => (
  506. <button
  507. key={device.ip_address}
  508. type="button"
  509. onClick={() => selectDevice(device)}
  510. 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"
  511. >
  512. <div className="flex items-center gap-2">
  513. <Plug className="w-4 h-4 text-bambu-green" />
  514. <div>
  515. <p className="text-sm text-white">{device.name}</p>
  516. <p className="text-xs text-bambu-gray">{device.ip_address}</p>
  517. </div>
  518. </div>
  519. {device.state && (
  520. <span className={`flex items-center gap-1 text-xs ${
  521. device.state === 'ON' ? 'text-bambu-green' : 'text-bambu-gray'
  522. }`}>
  523. <Power className="w-3 h-3" />
  524. {device.state}
  525. </span>
  526. )}
  527. </button>
  528. ))}
  529. </div>
  530. </div>
  531. )}
  532. {!isScanning && discoveredDevices.length === 0 && scanProgress.total > 0 && (
  533. <p className="text-xs text-bambu-gray text-center py-2">
  534. {t('smartPlugs.noDevicesFound')}
  535. </p>
  536. )}
  537. </div>
  538. )}
  539. {/* Home Assistant Entity Selector - only show when HA is selected */}
  540. {plugType === 'homeassistant' && (
  541. <div className="space-y-3">
  542. {/* HA not configured */}
  543. {!haConfigured && (
  544. <div className="space-y-3">
  545. <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">
  546. {t('smartPlugs.haNotConfigured')}{' '}
  547. <span className="font-medium">{t('smartPlugs.haSettingsPath')}</span>
  548. </div>
  549. <div>
  550. <label className="block text-sm text-bambu-gray mb-1 opacity-50">{t('smartPlugs.selectEntity')}</label>
  551. <select
  552. disabled
  553. 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"
  554. >
  555. <option>{t('smartPlugs.addSmartPlug.chooseEntity')}</option>
  556. </select>
  557. </div>
  558. </div>
  559. )}
  560. {/* HA configured - show loading/entities */}
  561. {haConfigured && (
  562. <>
  563. {haEntitiesLoading && (
  564. <div className="flex items-center justify-center py-4 text-bambu-gray">
  565. <Loader2 className="w-5 h-5 animate-spin mr-2" />
  566. {t('smartPlugs.loadingEntities')}
  567. </div>
  568. )}
  569. {haEntitiesError && (
  570. <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">
  571. {t('smartPlugs.failedToLoadEntities', { error: (haEntitiesError as Error).message })}
  572. </div>
  573. )}
  574. {/* Searchable Entity Dropdown */}
  575. {(() => {
  576. // Filter out entities already configured (except current plug when editing)
  577. const configuredEntityIds = existingPlugs
  578. ?.filter(p => p.ha_entity_id && p.id !== plug?.id)
  579. .map(p => p.ha_entity_id) || [];
  580. const availableEntities = (haEntities || []).filter(e => !configuredEntityIds.includes(e.entity_id));
  581. const selectedEntity = haEntities?.find(e => e.entity_id === haEntityId);
  582. return (
  583. <div ref={entityDropdownRef} className="relative">
  584. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.selectEntity')}</label>
  585. <div className="relative">
  586. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  587. <input
  588. type="text"
  589. value={isEntityDropdownOpen ? haEntitySearch : (selectedEntity ? `${selectedEntity.friendly_name} (${selectedEntity.entity_id})` : '')}
  590. onChange={(e) => {
  591. setHaEntitySearch(e.target.value);
  592. if (!isEntityDropdownOpen) setIsEntityDropdownOpen(true);
  593. }}
  594. onFocus={() => {
  595. setIsEntityDropdownOpen(true);
  596. setHaEntitySearch('');
  597. }}
  598. placeholder={t('smartPlugs.addSmartPlug.placeholders.searchEntities')}
  599. 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"
  600. />
  601. {haEntityId && !isEntityDropdownOpen && (
  602. <button
  603. type="button"
  604. onClick={() => {
  605. setHaEntityId('');
  606. setHaEntitySearch('');
  607. }}
  608. className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded"
  609. >
  610. <X className="w-4 h-4 text-bambu-gray hover:text-white" />
  611. </button>
  612. )}
  613. {haEntitiesLoading && (
  614. <Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray animate-spin" />
  615. )}
  616. </div>
  617. {/* Dropdown */}
  618. {isEntityDropdownOpen && (
  619. <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">
  620. {haEntitiesLoading && (
  621. <div className="px-3 py-2 text-sm text-bambu-gray flex items-center gap-2">
  622. <Loader2 className="w-4 h-4 animate-spin" />
  623. {t('smartPlugs.loading')}
  624. </div>
  625. )}
  626. {!haEntitiesLoading && availableEntities.length === 0 && (
  627. <div className="px-3 py-2 text-sm text-bambu-gray">
  628. {debouncedSearch
  629. ? t('smartPlugs.noEntitiesMatching', { search: debouncedSearch })
  630. : t('smartPlugs.noEntitiesAvailable')}
  631. </div>
  632. )}
  633. {!haEntitiesLoading && availableEntities.map((entity) => (
  634. <button
  635. key={entity.entity_id}
  636. type="button"
  637. onClick={() => {
  638. setHaEntityId(entity.entity_id);
  639. setIsEntityDropdownOpen(false);
  640. setHaEntitySearch('');
  641. // Auto-fill name
  642. if (!name) {
  643. setName(entity.friendly_name);
  644. }
  645. }}
  646. className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary transition-colors ${
  647. entity.entity_id === haEntityId ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'
  648. }`}
  649. >
  650. <div className="font-medium">{entity.friendly_name}</div>
  651. <div className="text-xs text-bambu-gray flex items-center justify-between">
  652. <span>{entity.entity_id}</span>
  653. <span className={entity.state === 'on' ? 'text-bambu-green' : ''}>{entity.state}</span>
  654. </div>
  655. </button>
  656. ))}
  657. </div>
  658. )}
  659. <p className="text-xs text-bambu-gray mt-1">
  660. {debouncedSearch
  661. ? t('smartPlugs.searchingEntities', { count: availableEntities.length })
  662. : t('smartPlugs.showingEntities', { count: availableEntities.length })}
  663. </p>
  664. </div>
  665. );
  666. })()}
  667. {/* Energy Monitoring Section (Optional) */}
  668. {haEntityId && haSensorEntities && haSensorEntities.length > 0 && (
  669. <div className="border-t border-bambu-dark-tertiary pt-4 mt-4 space-y-3">
  670. <div>
  671. <p className="text-white font-medium mb-1">{t('smartPlugs.energyMonitoringOptional')}</p>
  672. <p className="text-xs text-bambu-gray mb-3">
  673. {t('smartPlugs.energyMonitoringHint')}
  674. </p>
  675. </div>
  676. {/* Power Sensor (W) */}
  677. {(() => {
  678. const powerSensors = haSensorEntities.filter(s =>
  679. s.unit_of_measurement === 'W' || s.unit_of_measurement === 'kW' || s.unit_of_measurement === 'mW'
  680. );
  681. const filteredPowerSensors = powerSensorSearch
  682. ? powerSensors.filter(s =>
  683. s.entity_id.toLowerCase().includes(powerSensorSearch.toLowerCase()) ||
  684. s.friendly_name.toLowerCase().includes(powerSensorSearch.toLowerCase())
  685. )
  686. : powerSensors;
  687. const selectedPowerSensor = haSensorEntities.find(s => s.entity_id === haPowerEntity);
  688. return (
  689. <div ref={powerDropdownRef} className="relative">
  690. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.powerSensorW')}</label>
  691. <div className="relative">
  692. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  693. <input
  694. type="text"
  695. value={isPowerDropdownOpen ? powerSensorSearch : (selectedPowerSensor ? `${selectedPowerSensor.friendly_name} (${selectedPowerSensor.state} ${selectedPowerSensor.unit_of_measurement})` : '')}
  696. onChange={(e) => {
  697. setPowerSensorSearch(e.target.value);
  698. if (!isPowerDropdownOpen) setIsPowerDropdownOpen(true);
  699. }}
  700. onFocus={() => {
  701. setIsPowerDropdownOpen(true);
  702. setPowerSensorSearch('');
  703. }}
  704. placeholder={t('smartPlugs.addSmartPlug.placeholders.searchPowerSensors')}
  705. 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"
  706. />
  707. {haPowerEntity && !isPowerDropdownOpen && (
  708. <button
  709. type="button"
  710. onClick={() => {
  711. setHaPowerEntity('');
  712. setPowerSensorSearch('');
  713. }}
  714. className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded"
  715. >
  716. <X className="w-4 h-4 text-bambu-gray hover:text-white" />
  717. </button>
  718. )}
  719. </div>
  720. {isPowerDropdownOpen && (
  721. <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">
  722. <button
  723. type="button"
  724. onClick={() => {
  725. setHaPowerEntity('');
  726. setIsPowerDropdownOpen(false);
  727. setPowerSensorSearch('');
  728. }}
  729. className="w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary"
  730. >
  731. {t('smartPlugs.none')}
  732. </button>
  733. {filteredPowerSensors.map((sensor) => (
  734. <button
  735. key={sensor.entity_id}
  736. type="button"
  737. onClick={() => {
  738. setHaPowerEntity(sensor.entity_id);
  739. setIsPowerDropdownOpen(false);
  740. setPowerSensorSearch('');
  741. }}
  742. className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
  743. sensor.entity_id === haPowerEntity ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'
  744. }`}
  745. >
  746. <div className="font-medium">{sensor.friendly_name}</div>
  747. <div className="text-xs text-bambu-gray">{sensor.entity_id} • {sensor.state} {sensor.unit_of_measurement}</div>
  748. </button>
  749. ))}
  750. {filteredPowerSensors.length === 0 && (
  751. <div className="px-3 py-2 text-sm text-bambu-gray">{t('smartPlugs.noMatchingSensors')}</div>
  752. )}
  753. </div>
  754. )}
  755. </div>
  756. );
  757. })()}
  758. {/* Energy Today (kWh) */}
  759. {(() => {
  760. const energySensors = haSensorEntities.filter(s =>
  761. s.unit_of_measurement === 'kWh' || s.unit_of_measurement === 'Wh' || s.unit_of_measurement === 'MWh'
  762. );
  763. const filteredEnergySensors = energyTodaySearch
  764. ? energySensors.filter(s =>
  765. s.entity_id.toLowerCase().includes(energyTodaySearch.toLowerCase()) ||
  766. s.friendly_name.toLowerCase().includes(energyTodaySearch.toLowerCase())
  767. )
  768. : energySensors;
  769. const selectedSensor = haSensorEntities.find(s => s.entity_id === haEnergyTodayEntity);
  770. return (
  771. <div ref={energyTodayDropdownRef} className="relative">
  772. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.energyTodayKwh')}</label>
  773. <div className="relative">
  774. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  775. <input
  776. type="text"
  777. value={isEnergyTodayDropdownOpen ? energyTodaySearch : (selectedSensor ? `${selectedSensor.friendly_name} (${selectedSensor.state} ${selectedSensor.unit_of_measurement})` : '')}
  778. onChange={(e) => {
  779. setEnergyTodaySearch(e.target.value);
  780. if (!isEnergyTodayDropdownOpen) setIsEnergyTodayDropdownOpen(true);
  781. }}
  782. onFocus={() => {
  783. setIsEnergyTodayDropdownOpen(true);
  784. setEnergyTodaySearch('');
  785. }}
  786. placeholder={t('smartPlugs.addSmartPlug.placeholders.searchEnergySensors')}
  787. 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"
  788. />
  789. {haEnergyTodayEntity && !isEnergyTodayDropdownOpen && (
  790. <button
  791. type="button"
  792. onClick={() => {
  793. setHaEnergyTodayEntity('');
  794. setEnergyTodaySearch('');
  795. }}
  796. className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded"
  797. >
  798. <X className="w-4 h-4 text-bambu-gray hover:text-white" />
  799. </button>
  800. )}
  801. </div>
  802. {isEnergyTodayDropdownOpen && (
  803. <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">
  804. <button
  805. type="button"
  806. onClick={() => {
  807. setHaEnergyTodayEntity('');
  808. setIsEnergyTodayDropdownOpen(false);
  809. setEnergyTodaySearch('');
  810. }}
  811. className="w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary"
  812. >
  813. {t('smartPlugs.none')}
  814. </button>
  815. {filteredEnergySensors.map((sensor) => (
  816. <button
  817. key={sensor.entity_id}
  818. type="button"
  819. onClick={() => {
  820. setHaEnergyTodayEntity(sensor.entity_id);
  821. setIsEnergyTodayDropdownOpen(false);
  822. setEnergyTodaySearch('');
  823. }}
  824. className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
  825. sensor.entity_id === haEnergyTodayEntity ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'
  826. }`}
  827. >
  828. <div className="font-medium">{sensor.friendly_name}</div>
  829. <div className="text-xs text-bambu-gray">{sensor.entity_id} • {sensor.state} {sensor.unit_of_measurement}</div>
  830. </button>
  831. ))}
  832. {filteredEnergySensors.length === 0 && (
  833. <div className="px-3 py-2 text-sm text-bambu-gray">{t('smartPlugs.noMatchingSensors')}</div>
  834. )}
  835. </div>
  836. )}
  837. </div>
  838. );
  839. })()}
  840. {/* Total Energy (kWh) */}
  841. {(() => {
  842. const energySensors = haSensorEntities.filter(s =>
  843. s.unit_of_measurement === 'kWh' || s.unit_of_measurement === 'Wh' || s.unit_of_measurement === 'MWh'
  844. );
  845. const filteredEnergySensors = energyTotalSearch
  846. ? energySensors.filter(s =>
  847. s.entity_id.toLowerCase().includes(energyTotalSearch.toLowerCase()) ||
  848. s.friendly_name.toLowerCase().includes(energyTotalSearch.toLowerCase())
  849. )
  850. : energySensors;
  851. const selectedSensor = haSensorEntities.find(s => s.entity_id === haEnergyTotalEntity);
  852. return (
  853. <div ref={energyTotalDropdownRef} className="relative">
  854. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.totalEnergyKwh')}</label>
  855. <div className="relative">
  856. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  857. <input
  858. type="text"
  859. value={isEnergyTotalDropdownOpen ? energyTotalSearch : (selectedSensor ? `${selectedSensor.friendly_name} (${selectedSensor.state} ${selectedSensor.unit_of_measurement})` : '')}
  860. onChange={(e) => {
  861. setEnergyTotalSearch(e.target.value);
  862. if (!isEnergyTotalDropdownOpen) setIsEnergyTotalDropdownOpen(true);
  863. }}
  864. onFocus={() => {
  865. setIsEnergyTotalDropdownOpen(true);
  866. setEnergyTotalSearch('');
  867. }}
  868. placeholder={t('smartPlugs.addSmartPlug.placeholders.searchEnergySensors')}
  869. 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"
  870. />
  871. {haEnergyTotalEntity && !isEnergyTotalDropdownOpen && (
  872. <button
  873. type="button"
  874. onClick={() => {
  875. setHaEnergyTotalEntity('');
  876. setEnergyTotalSearch('');
  877. }}
  878. className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded"
  879. >
  880. <X className="w-4 h-4 text-bambu-gray hover:text-white" />
  881. </button>
  882. )}
  883. </div>
  884. {isEnergyTotalDropdownOpen && (
  885. <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">
  886. <button
  887. type="button"
  888. onClick={() => {
  889. setHaEnergyTotalEntity('');
  890. setIsEnergyTotalDropdownOpen(false);
  891. setEnergyTotalSearch('');
  892. }}
  893. className="w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary"
  894. >
  895. {t('smartPlugs.none')}
  896. </button>
  897. {filteredEnergySensors.map((sensor) => (
  898. <button
  899. key={sensor.entity_id}
  900. type="button"
  901. onClick={() => {
  902. setHaEnergyTotalEntity(sensor.entity_id);
  903. setIsEnergyTotalDropdownOpen(false);
  904. setEnergyTotalSearch('');
  905. }}
  906. className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
  907. sensor.entity_id === haEnergyTotalEntity ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'
  908. }`}
  909. >
  910. <div className="font-medium">{sensor.friendly_name}</div>
  911. <div className="text-xs text-bambu-gray">{sensor.entity_id} • {sensor.state} {sensor.unit_of_measurement}</div>
  912. </button>
  913. ))}
  914. {filteredEnergySensors.length === 0 && (
  915. <div className="px-3 py-2 text-sm text-bambu-gray">{t('smartPlugs.noMatchingSensors')}</div>
  916. )}
  917. </div>
  918. )}
  919. </div>
  920. );
  921. })()}
  922. </div>
  923. )}
  924. </>
  925. )}
  926. </div>
  927. )}
  928. {/* MQTT Configuration - only show when MQTT is selected */}
  929. {plugType === 'mqtt' && (
  930. <div className="space-y-3">
  931. {/* MQTT broker not configured */}
  932. {!settings?.mqtt_broker && (
  933. <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">
  934. {t('smartPlugs.mqttNotConfigured')}{' '}
  935. <span className="font-medium">{t('smartPlugs.mqttSettingsPath')}</span>
  936. {' '}{t('smartPlugs.mqttNotConfiguredSuffix')}
  937. </div>
  938. )}
  939. {/* MQTT broker configured - show fields */}
  940. {settings?.mqtt_broker && (
  941. <>
  942. <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">
  943. <p className="font-medium mb-1">{t('smartPlugs.monitorOnly')}</p>
  944. <p className="text-xs opacity-80">
  945. {t('smartPlugs.mqttMonitorOnlyDescription')}
  946. </p>
  947. </div>
  948. {/* Power Section */}
  949. <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
  950. <p className="text-white font-medium text-sm">{t('smartPlugs.powerMonitoring')}</p>
  951. <div>
  952. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.topic')}</label>
  953. <input
  954. type="text"
  955. value={mqttPowerTopic}
  956. onChange={(e) => setMqttPowerTopic(e.target.value)}
  957. placeholder="zigbee2mqtt/shelly-working-room"
  958. 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"
  959. />
  960. </div>
  961. <div className="grid grid-cols-2 gap-3">
  962. <div>
  963. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.jsonPath')}</label>
  964. <input
  965. type="text"
  966. value={mqttPowerPath}
  967. onChange={(e) => setMqttPowerPath(e.target.value)}
  968. placeholder="power_l1"
  969. 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"
  970. />
  971. </div>
  972. <div>
  973. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.multiplier')}</label>
  974. <input
  975. type="text"
  976. value={mqttPowerMultiplier}
  977. onChange={(e) => setMqttPowerMultiplier(e.target.value)}
  978. placeholder="1"
  979. 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"
  980. />
  981. </div>
  982. </div>
  983. <p className="text-xs text-bambu-gray" style={{ whiteSpace: 'pre-line' }}>
  984. {t('smartPlugs.mqttPowerHint')}
  985. </p>
  986. </div>
  987. {/* Energy Section */}
  988. <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
  989. <p className="text-white font-medium text-sm">{t('smartPlugs.energyMonitoring')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></p>
  990. <div>
  991. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.topic')}</label>
  992. <input
  993. type="text"
  994. value={mqttEnergyTopic}
  995. onChange={(e) => setMqttEnergyTopic(e.target.value)}
  996. placeholder="Same as power topic, or different"
  997. 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"
  998. />
  999. </div>
  1000. <div className="grid grid-cols-2 gap-3">
  1001. <div>
  1002. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.jsonPath')}</label>
  1003. <input
  1004. type="text"
  1005. value={mqttEnergyPath}
  1006. onChange={(e) => setMqttEnergyPath(e.target.value)}
  1007. placeholder="energy_l1"
  1008. 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"
  1009. />
  1010. </div>
  1011. <div>
  1012. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.multiplier')}</label>
  1013. <input
  1014. type="text"
  1015. value={mqttEnergyMultiplier}
  1016. onChange={(e) => setMqttEnergyMultiplier(e.target.value)}
  1017. placeholder="1"
  1018. 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"
  1019. />
  1020. </div>
  1021. </div>
  1022. <p className="text-xs text-bambu-gray" style={{ whiteSpace: 'pre-line' }}>
  1023. {t('smartPlugs.mqttEnergyHint')}
  1024. </p>
  1025. </div>
  1026. {/* State Section */}
  1027. <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
  1028. <p className="text-white font-medium text-sm">{t('smartPlugs.stateMonitoring')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></p>
  1029. <div>
  1030. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.topic')}</label>
  1031. <input
  1032. type="text"
  1033. value={mqttStateTopic}
  1034. onChange={(e) => setMqttStateTopic(e.target.value)}
  1035. placeholder="Same as power topic, or different"
  1036. 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"
  1037. />
  1038. </div>
  1039. <div className="grid grid-cols-2 gap-3">
  1040. <div>
  1041. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.jsonPath')}</label>
  1042. <input
  1043. type="text"
  1044. value={mqttStatePath}
  1045. onChange={(e) => setMqttStatePath(e.target.value)}
  1046. placeholder="state_l1"
  1047. 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"
  1048. />
  1049. </div>
  1050. <div>
  1051. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.onValue')}</label>
  1052. <input
  1053. type="text"
  1054. value={mqttStateOnValue}
  1055. onChange={(e) => setMqttStateOnValue(e.target.value)}
  1056. placeholder={t('smartPlugs.addSmartPlug.placeholders.mqttStateOnValue')}
  1057. 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"
  1058. />
  1059. </div>
  1060. </div>
  1061. <p className="text-xs text-bambu-gray" style={{ whiteSpace: 'pre-line' }}>
  1062. {t('smartPlugs.mqttStateHint')}
  1063. </p>
  1064. </div>
  1065. </>
  1066. )}
  1067. </div>
  1068. )}
  1069. {/* REST API Section */}
  1070. {plugType === 'rest' && (
  1071. <div className="space-y-3">
  1072. {/* Control Section */}
  1073. <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
  1074. <p className="text-white font-medium text-sm">{t('smartPlugs.restControl')}</p>
  1075. <div>
  1076. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restMethod')}</label>
  1077. <select
  1078. value={restMethod}
  1079. onChange={(e) => setRestMethod(e.target.value)}
  1080. 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"
  1081. >
  1082. <option value="GET">GET</option>
  1083. <option value="POST">POST</option>
  1084. <option value="PUT">PUT</option>
  1085. <option value="PATCH">PATCH</option>
  1086. </select>
  1087. </div>
  1088. <div>
  1089. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restOnUrl')}</label>
  1090. <input
  1091. type="text"
  1092. value={restOnUrl}
  1093. onChange={(e) => { setRestOnUrl(e.target.value); setTestResult(null); }}
  1094. placeholder="http://openhab:8080/rest/items/MyPlug"
  1095. 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"
  1096. />
  1097. </div>
  1098. <div>
  1099. <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>
  1100. <input
  1101. type="text"
  1102. value={restOnBody}
  1103. onChange={(e) => setRestOnBody(e.target.value)}
  1104. placeholder={t('smartPlugs.restBodyHint')}
  1105. 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"
  1106. />
  1107. </div>
  1108. <div>
  1109. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restOffUrl')}</label>
  1110. <input
  1111. type="text"
  1112. value={restOffUrl}
  1113. onChange={(e) => { setRestOffUrl(e.target.value); setTestResult(null); }}
  1114. placeholder="http://openhab:8080/rest/items/MyPlug"
  1115. 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"
  1116. />
  1117. </div>
  1118. <div>
  1119. <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>
  1120. <input
  1121. type="text"
  1122. value={restOffBody}
  1123. onChange={(e) => setRestOffBody(e.target.value)}
  1124. placeholder={t('smartPlugs.restBodyHint')}
  1125. 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"
  1126. />
  1127. </div>
  1128. </div>
  1129. {/* Headers Section */}
  1130. <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
  1131. <p className="text-white font-medium text-sm">{t('smartPlugs.restHeaders')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></p>
  1132. <div>
  1133. <textarea
  1134. value={restHeaders}
  1135. onChange={(e) => setRestHeaders(e.target.value)}
  1136. placeholder={t('smartPlugs.restHeadersHint')}
  1137. rows={2}
  1138. 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"
  1139. />
  1140. </div>
  1141. </div>
  1142. {/* Status Polling Section (optional) */}
  1143. <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
  1144. <p className="text-white font-medium text-sm">{t('smartPlugs.stateMonitoring')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></p>
  1145. <div>
  1146. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restStatusUrl')}</label>
  1147. <input
  1148. type="text"
  1149. value={restStatusUrl}
  1150. onChange={(e) => setRestStatusUrl(e.target.value)}
  1151. placeholder={t('smartPlugs.restStatusHint')}
  1152. 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"
  1153. />
  1154. </div>
  1155. <div className="grid grid-cols-2 gap-3">
  1156. <div>
  1157. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restStatusPath')}</label>
  1158. <input
  1159. type="text"
  1160. value={restStatusPath}
  1161. onChange={(e) => setRestStatusPath(e.target.value)}
  1162. placeholder={t('smartPlugs.restPathHint')}
  1163. 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"
  1164. />
  1165. </div>
  1166. <div>
  1167. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restStatusOnValue')}</label>
  1168. <input
  1169. type="text"
  1170. value={restStatusOnValue}
  1171. onChange={(e) => setRestStatusOnValue(e.target.value)}
  1172. placeholder="ON"
  1173. 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"
  1174. />
  1175. </div>
  1176. </div>
  1177. </div>
  1178. {/* Energy Monitoring (optional) */}
  1179. <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
  1180. <p className="text-white font-medium text-sm">{t('smartPlugs.energyMonitoring')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></p>
  1181. {/* Power */}
  1182. <div>
  1183. <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>
  1184. <input
  1185. type="text"
  1186. value={restPowerUrl}
  1187. onChange={(e) => setRestPowerUrl(e.target.value)}
  1188. placeholder={t('smartPlugs.restPowerUrlHint')}
  1189. 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"
  1190. />
  1191. </div>
  1192. <div className="grid grid-cols-2 gap-3">
  1193. <div>
  1194. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restPowerPath')}</label>
  1195. <input
  1196. type="text"
  1197. value={restPowerPath}
  1198. onChange={(e) => setRestPowerPath(e.target.value)}
  1199. placeholder={t('smartPlugs.restPathHint')}
  1200. 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"
  1201. />
  1202. </div>
  1203. <div>
  1204. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restPowerMultiplier')}</label>
  1205. <input
  1206. type="text"
  1207. value={restPowerMultiplier}
  1208. onChange={(e) => setRestPowerMultiplier(e.target.value)}
  1209. placeholder="1"
  1210. 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"
  1211. />
  1212. </div>
  1213. </div>
  1214. {/* Energy */}
  1215. <div>
  1216. <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>
  1217. <input
  1218. type="text"
  1219. value={restEnergyUrl}
  1220. onChange={(e) => setRestEnergyUrl(e.target.value)}
  1221. placeholder={t('smartPlugs.restEnergyUrlHint')}
  1222. 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"
  1223. />
  1224. </div>
  1225. <div className="grid grid-cols-2 gap-3">
  1226. <div>
  1227. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restEnergyPath')}</label>
  1228. <input
  1229. type="text"
  1230. value={restEnergyPath}
  1231. onChange={(e) => setRestEnergyPath(e.target.value)}
  1232. placeholder={t('smartPlugs.restPathHint')}
  1233. 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"
  1234. />
  1235. </div>
  1236. <div>
  1237. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restEnergyMultiplier')}</label>
  1238. <input
  1239. type="text"
  1240. value={restEnergyMultiplier}
  1241. onChange={(e) => setRestEnergyMultiplier(e.target.value)}
  1242. placeholder="1"
  1243. 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"
  1244. />
  1245. </div>
  1246. </div>
  1247. <p className="text-xs text-bambu-gray">
  1248. {t('smartPlugs.restEnergyHint')}
  1249. </p>
  1250. {/* Lifetime counter (#2539) — the only energy figure a Shelly has. */}
  1251. <div className="grid grid-cols-2 gap-3">
  1252. <div>
  1253. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restEnergyTotalPath')}</label>
  1254. <input
  1255. type="text"
  1256. value={restEnergyTotalPath}
  1257. onChange={(e) => setRestEnergyTotalPath(e.target.value)}
  1258. placeholder={t('smartPlugs.restEnergyTotalPathHint')}
  1259. 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"
  1260. />
  1261. </div>
  1262. <div>
  1263. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.restEnergyTotalMultiplier')}</label>
  1264. <input
  1265. type="text"
  1266. value={restEnergyTotalMultiplier}
  1267. onChange={(e) => setRestEnergyTotalMultiplier(e.target.value)}
  1268. placeholder="1"
  1269. 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"
  1270. />
  1271. </div>
  1272. </div>
  1273. <p className="text-xs text-bambu-gray">
  1274. {t('smartPlugs.restEnergyTotalHint')}
  1275. </p>
  1276. </div>
  1277. {/* Test Connection */}
  1278. {(restOnUrl.trim() || restOffUrl.trim()) && (
  1279. <div className="flex gap-2">
  1280. <Button
  1281. type="button"
  1282. variant="secondary"
  1283. onClick={async () => {
  1284. setTestResult(null);
  1285. try {
  1286. const url = restOnUrl.trim() || restOffUrl.trim();
  1287. const result = await api.testRESTConnection(url, restMethod, restHeaders.trim() || null);
  1288. setTestResult({ success: result.success });
  1289. if (!result.success) {
  1290. setError(result.error || t('smartPlugs.addSmartPlug.connectionFailed'));
  1291. }
  1292. } catch {
  1293. setTestResult({ success: false });
  1294. setError(t('smartPlugs.addSmartPlug.connectionFailed'));
  1295. }
  1296. }}
  1297. className="w-full"
  1298. >
  1299. <Wifi className="w-4 h-4" />
  1300. {t('smartPlugs.testConnection')}
  1301. </Button>
  1302. </div>
  1303. )}
  1304. {testResult && (
  1305. <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'}`}>
  1306. {testResult.success ? <CheckCircle className="w-4 h-4" /> : <WifiOff className="w-4 h-4" />}
  1307. {testResult.success ? t('smartPlugs.connectionSuccess') : t('smartPlugs.addSmartPlug.connectionFailed')}
  1308. </div>
  1309. )}
  1310. </div>
  1311. )}
  1312. {/* IP Address - only show for Tasmota */}
  1313. {plugType === 'tasmota' && (
  1314. <div>
  1315. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.ipAddress')}</label>
  1316. <div className="flex gap-2">
  1317. <input
  1318. type="text"
  1319. value={ipAddress}
  1320. onChange={(e) => {
  1321. setIpAddress(e.target.value);
  1322. setTestResult(null);
  1323. }}
  1324. placeholder="192.168.1.100"
  1325. 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"
  1326. />
  1327. <Button
  1328. type="button"
  1329. variant="secondary"
  1330. onClick={() => testMutation.mutate()}
  1331. disabled={!ipAddress.trim() || testMutation.isPending}
  1332. >
  1333. {testMutation.isPending ? (
  1334. <Loader2 className="w-4 h-4 animate-spin" />
  1335. ) : (
  1336. <Wifi className="w-4 h-4" />
  1337. )}
  1338. {t('smartPlugs.test')}
  1339. </Button>
  1340. </div>
  1341. </div>
  1342. )}
  1343. {/* Test Result - only show for Tasmota */}
  1344. {plugType === 'tasmota' && testResult && (
  1345. <div className={`p-3 rounded-lg flex items-center gap-2 ${
  1346. testResult.success
  1347. ? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'
  1348. : 'bg-red-100 dark:bg-red-500/20 border border-red-400 dark:border-red-500/50 text-red-700 dark:text-red-400'
  1349. }`}>
  1350. {testResult.success ? (
  1351. <>
  1352. <CheckCircle className="w-5 h-5" />
  1353. <div>
  1354. <p className="font-medium">{t('smartPlugs.connectedResult')}</p>
  1355. <p className="text-sm opacity-80">
  1356. {testResult.device_name && t('smartPlugs.deviceLabel', { name: testResult.device_name })}
  1357. {t('smartPlugs.stateLabel', { state: testResult.state })}
  1358. </p>
  1359. </div>
  1360. </>
  1361. ) : (
  1362. <>
  1363. <WifiOff className="w-5 h-5" />
  1364. <span>{t('smartPlugs.addSmartPlug.connectionFailed')}</span>
  1365. </>
  1366. )}
  1367. </div>
  1368. )}
  1369. {/* Name */}
  1370. <div>
  1371. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.nameLabel')}</label>
  1372. <input
  1373. type="text"
  1374. value={name}
  1375. onChange={(e) => setName(e.target.value)}
  1376. placeholder={t('smartPlugs.addSmartPlug.placeholders.plugName')}
  1377. 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"
  1378. />
  1379. </div>
  1380. {/* Authentication (optional) - only show for Tasmota */}
  1381. {plugType === 'tasmota' && (
  1382. <>
  1383. <div className="grid grid-cols-2 gap-3">
  1384. <div>
  1385. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.username')}</label>
  1386. <input
  1387. type="text"
  1388. value={username}
  1389. onChange={(e) => setUsername(e.target.value)}
  1390. placeholder="admin"
  1391. 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"
  1392. />
  1393. </div>
  1394. <div>
  1395. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.password')}</label>
  1396. <input
  1397. type="password"
  1398. value={password}
  1399. onChange={(e) => setPassword(e.target.value)}
  1400. placeholder="********"
  1401. 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"
  1402. />
  1403. </div>
  1404. </div>
  1405. <p className="text-xs text-bambu-gray -mt-2">
  1406. {t('smartPlugs.authHint')}
  1407. </p>
  1408. </>
  1409. )}
  1410. {/* Link to Printer - not shown for MQTT plugs (monitor-only) */}
  1411. {plugType !== 'mqtt' && (
  1412. <div>
  1413. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.linkToPrinter')}</label>
  1414. <select
  1415. value={printerId ?? ''}
  1416. onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
  1417. 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"
  1418. >
  1419. <option value="">{t('smartPlugs.noPrinter')}</option>
  1420. {availablePrinters?.map((p) => (
  1421. <option key={p.id} value={p.id}>
  1422. {p.name}
  1423. </option>
  1424. ))}
  1425. </select>
  1426. <p className="text-xs text-bambu-gray mt-1">
  1427. {t('smartPlugs.linkingDescription')}
  1428. </p>
  1429. {/* Whether the plug feeds the printer itself, or is an accessory
  1430. that merely follows the print cycle (#2629). */}
  1431. {printerId !== null && (
  1432. <div className="flex items-center justify-between mt-3">
  1433. <div className="pr-3">
  1434. <p className="text-sm text-white">{t('smartPlugs.controlsPrinterPower')}</p>
  1435. <p className="text-xs text-bambu-gray">{t('smartPlugs.controlsPrinterPowerDescription')}</p>
  1436. </div>
  1437. <label className="relative inline-flex items-center cursor-pointer shrink-0">
  1438. <input
  1439. type="checkbox"
  1440. checked={controlsPrinterPower}
  1441. onChange={(e) => setControlsPrinterPower(e.target.checked)}
  1442. className="sr-only peer"
  1443. />
  1444. <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>
  1445. </label>
  1446. </div>
  1447. )}
  1448. </div>
  1449. )}
  1450. {/* Power Alerts */}
  1451. <div className="border-t border-bambu-dark-tertiary pt-4">
  1452. <div className="flex items-center justify-between mb-3">
  1453. <div className="flex items-center gap-2">
  1454. <Bell className="w-4 h-4 text-bambu-green" />
  1455. <span className="text-white font-medium">{t('smartPlugs.powerAlerts')}</span>
  1456. </div>
  1457. <label className="relative inline-flex items-center cursor-pointer">
  1458. <input
  1459. type="checkbox"
  1460. checked={powerAlertEnabled}
  1461. onChange={(e) => setPowerAlertEnabled(e.target.checked)}
  1462. className="sr-only peer"
  1463. />
  1464. <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>
  1465. </label>
  1466. </div>
  1467. {powerAlertEnabled && (
  1468. <div className="space-y-3">
  1469. <div className="grid grid-cols-2 gap-3">
  1470. <div>
  1471. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.alertAbove')}</label>
  1472. <input
  1473. type="number"
  1474. value={powerAlertHigh}
  1475. onChange={(e) => setPowerAlertHigh(e.target.value)}
  1476. placeholder="e.g. 200"
  1477. min="0"
  1478. max="5000"
  1479. 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"
  1480. />
  1481. </div>
  1482. <div>
  1483. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.alertBelow')}</label>
  1484. <input
  1485. type="number"
  1486. value={powerAlertLow}
  1487. onChange={(e) => setPowerAlertLow(e.target.value)}
  1488. placeholder="e.g. 10"
  1489. min="0"
  1490. max="5000"
  1491. 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"
  1492. />
  1493. </div>
  1494. </div>
  1495. <p className="text-xs text-bambu-gray">
  1496. {t('smartPlugs.alertDescription')}
  1497. </p>
  1498. </div>
  1499. )}
  1500. </div>
  1501. {/* Schedule - not shown for MQTT plugs (monitor-only) */}
  1502. {plugType !== 'mqtt' && (
  1503. <div className="border-t border-bambu-dark-tertiary pt-4">
  1504. <div className="flex items-center justify-between mb-3">
  1505. <div className="flex items-center gap-2">
  1506. <Clock className="w-4 h-4 text-bambu-green" />
  1507. <span className="text-white font-medium">{t('smartPlugs.dailySchedule')}</span>
  1508. </div>
  1509. <label className="relative inline-flex items-center cursor-pointer">
  1510. <input
  1511. type="checkbox"
  1512. checked={scheduleEnabled}
  1513. onChange={(e) => setScheduleEnabled(e.target.checked)}
  1514. className="sr-only peer"
  1515. />
  1516. <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>
  1517. </label>
  1518. </div>
  1519. {scheduleEnabled && (
  1520. <div className="space-y-3">
  1521. <div className="grid grid-cols-2 gap-3">
  1522. <div>
  1523. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.turnOnAt')}</label>
  1524. <input
  1525. type="time"
  1526. value={scheduleOnTime}
  1527. onChange={(e) => setScheduleOnTime(e.target.value)}
  1528. 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"
  1529. />
  1530. </div>
  1531. <div>
  1532. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.turnOffAt')}</label>
  1533. <input
  1534. type="time"
  1535. value={scheduleOffTime}
  1536. onChange={(e) => setScheduleOffTime(e.target.value)}
  1537. 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"
  1538. />
  1539. </div>
  1540. </div>
  1541. <p className="text-xs text-bambu-gray">
  1542. {t('smartPlugs.scheduleDescription')}
  1543. </p>
  1544. </div>
  1545. )}
  1546. </div>
  1547. )}
  1548. {/* Switchbar Visibility */}
  1549. <div className="border-t border-bambu-dark-tertiary pt-4">
  1550. <div className="flex items-center justify-between">
  1551. <div className="flex items-center gap-2">
  1552. <LayoutGrid className="w-4 h-4 text-bambu-green" />
  1553. <div>
  1554. <span className="text-white font-medium">{t('smartPlugs.showInSwitchbar')}</span>
  1555. <p className="text-xs text-bambu-gray">{t('smartPlugs.quickAccessSidebar')}</p>
  1556. </div>
  1557. </div>
  1558. <label className="relative inline-flex items-center cursor-pointer">
  1559. <input
  1560. type="checkbox"
  1561. checked={showInSwitchbar}
  1562. onChange={(e) => setShowInSwitchbar(e.target.checked)}
  1563. className="sr-only peer"
  1564. />
  1565. <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>
  1566. </label>
  1567. </div>
  1568. </div>
  1569. {/* Printer Card Visibility - only for HA entities */}
  1570. {plugType === 'homeassistant' && (
  1571. <div className="border-t border-bambu-dark-tertiary pt-4">
  1572. <div className="flex items-center justify-between">
  1573. <div className="flex items-center gap-2">
  1574. <Eye className="w-4 h-4 text-bambu-green" />
  1575. <div>
  1576. <span className="text-white font-medium">{t('smartPlugs.showOnPrinterCard')}</span>
  1577. <p className="text-xs text-bambu-gray">{t('smartPlugs.displayOnPrinterCard')}</p>
  1578. </div>
  1579. </div>
  1580. <label className="relative inline-flex items-center cursor-pointer">
  1581. <input
  1582. type="checkbox"
  1583. checked={showOnPrinterCard}
  1584. onChange={(e) => setShowOnPrinterCard(e.target.checked)}
  1585. className="sr-only peer"
  1586. />
  1587. <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>
  1588. </label>
  1589. </div>
  1590. </div>
  1591. )}
  1592. {/* Actions */}
  1593. <div className="flex gap-3 pt-2">
  1594. <Button
  1595. type="button"
  1596. variant="secondary"
  1597. onClick={onClose}
  1598. className="flex-1"
  1599. >
  1600. {t('smartPlugs.cancel')}
  1601. </Button>
  1602. <Button
  1603. type="submit"
  1604. disabled={isPending}
  1605. className="flex-1"
  1606. >
  1607. {isPending ? (
  1608. <Loader2 className="w-4 h-4 animate-spin" />
  1609. ) : (
  1610. <Save className="w-4 h-4" />
  1611. )}
  1612. {isEditing ? t('smartPlugs.save') : t('smartPlugs.add')}
  1613. </Button>
  1614. </div>
  1615. </form>
  1616. </div>
  1617. </div>
  1618. );
  1619. }