AddSmartPlugModal.tsx 89 KB

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