AddSmartPlugModal.tsx 68 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374
  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 } 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'>(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. // HA energy sensor entities (optional)
  42. const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || '');
  43. const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_entity || '');
  44. const [haEnergyTotalEntity, setHaEnergyTotalEntity] = useState(plug?.ha_energy_total_entity || '');
  45. // HA entity search
  46. const [haEntitySearch, setHaEntitySearch] = useState('');
  47. const [debouncedSearch, setDebouncedSearch] = useState('');
  48. const [isEntityDropdownOpen, setIsEntityDropdownOpen] = useState(false);
  49. const entityDropdownRef = useRef<HTMLDivElement>(null);
  50. // Energy sensor search states
  51. const [powerSensorSearch, setPowerSensorSearch] = useState('');
  52. const [isPowerDropdownOpen, setIsPowerDropdownOpen] = useState(false);
  53. const powerDropdownRef = useRef<HTMLDivElement>(null);
  54. const [energyTodaySearch, setEnergyTodaySearch] = useState('');
  55. const [isEnergyTodayDropdownOpen, setIsEnergyTodayDropdownOpen] = useState(false);
  56. const energyTodayDropdownRef = useRef<HTMLDivElement>(null);
  57. const [energyTotalSearch, setEnergyTotalSearch] = useState('');
  58. const [isEnergyTotalDropdownOpen, setIsEnergyTotalDropdownOpen] = useState(false);
  59. const energyTotalDropdownRef = useRef<HTMLDivElement>(null);
  60. const [printerId, setPrinterId] = useState<number | null>(plug?.printer_id || null);
  61. const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
  62. const [error, setError] = useState<string | null>(null);
  63. // Power alert settings
  64. const [powerAlertEnabled, setPowerAlertEnabled] = useState(plug?.power_alert_enabled || false);
  65. const [powerAlertHigh, setPowerAlertHigh] = useState<string>(plug?.power_alert_high?.toString() || '');
  66. const [powerAlertLow, setPowerAlertLow] = useState<string>(plug?.power_alert_low?.toString() || '');
  67. // Schedule settings
  68. const [scheduleEnabled, setScheduleEnabled] = useState(plug?.schedule_enabled || false);
  69. const [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_time || '');
  70. const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
  71. // Visibility options
  72. const [showInSwitchbar, setShowInSwitchbar] = useState(plug?.show_in_switchbar || false);
  73. const [showOnPrinterCard, setShowOnPrinterCard] = useState(plug?.show_on_printer_card ?? true);
  74. // Discovery state
  75. const [isScanning, setIsScanning] = useState(false);
  76. const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 });
  77. const [discoveredDevices, setDiscoveredDevices] = useState<DiscoveredTasmotaDevice[]>([]);
  78. const scanPollRef = useRef<NodeJS.Timeout | null>(null);
  79. // Fetch printers for linking
  80. const { data: printers } = useQuery({
  81. queryKey: ['printers'],
  82. queryFn: api.getPrinters,
  83. });
  84. // Fetch existing plugs to check for conflicts
  85. const { data: existingPlugs } = useQuery({
  86. queryKey: ['smart-plugs'],
  87. queryFn: api.getSmartPlugs,
  88. });
  89. // Fetch settings to check if HA is configured
  90. const { data: settings } = useQuery({
  91. queryKey: ['settings'],
  92. queryFn: api.getSettings,
  93. });
  94. // Check if HA is properly configured
  95. const haConfigured = !!(settings?.ha_enabled && settings?.ha_url && settings?.ha_token);
  96. // Debounce search input
  97. useEffect(() => {
  98. const timer = setTimeout(() => {
  99. setDebouncedSearch(haEntitySearch);
  100. }, 300);
  101. return () => clearTimeout(timer);
  102. }, [haEntitySearch]);
  103. // Close dropdowns when clicking outside
  104. useEffect(() => {
  105. const handleClickOutside = (e: MouseEvent) => {
  106. if (entityDropdownRef.current && !entityDropdownRef.current.contains(e.target as Node)) {
  107. setIsEntityDropdownOpen(false);
  108. }
  109. if (powerDropdownRef.current && !powerDropdownRef.current.contains(e.target as Node)) {
  110. setIsPowerDropdownOpen(false);
  111. }
  112. if (energyTodayDropdownRef.current && !energyTodayDropdownRef.current.contains(e.target as Node)) {
  113. setIsEnergyTodayDropdownOpen(false);
  114. }
  115. if (energyTotalDropdownRef.current && !energyTotalDropdownRef.current.contains(e.target as Node)) {
  116. setIsEnergyTotalDropdownOpen(false);
  117. }
  118. };
  119. document.addEventListener('mousedown', handleClickOutside);
  120. return () => document.removeEventListener('mousedown', handleClickOutside);
  121. }, []);
  122. // Fetch Home Assistant entities when in HA mode AND HA is configured
  123. const { data: haEntities, isLoading: haEntitiesLoading, error: haEntitiesError } = useQuery({
  124. queryKey: ['ha-entities', debouncedSearch],
  125. queryFn: () => api.getHAEntities(debouncedSearch || undefined),
  126. enabled: plugType === 'homeassistant' && haConfigured,
  127. retry: false,
  128. staleTime: 0,
  129. });
  130. // Fetch Home Assistant sensor entities for energy monitoring
  131. const { data: haSensorEntities } = useQuery({
  132. queryKey: ['ha-sensor-entities'],
  133. queryFn: api.getHASensorEntities,
  134. enabled: plugType === 'homeassistant' && haConfigured,
  135. retry: false,
  136. staleTime: 0,
  137. });
  138. // Close on Escape key and cleanup scan polling
  139. useEffect(() => {
  140. const handleKeyDown = (e: KeyboardEvent) => {
  141. if (e.key === 'Escape') onClose();
  142. };
  143. window.addEventListener('keydown', handleKeyDown);
  144. return () => {
  145. window.removeEventListener('keydown', handleKeyDown);
  146. if (scanPollRef.current) {
  147. clearInterval(scanPollRef.current);
  148. }
  149. };
  150. }, [onClose]);
  151. // Start scanning for Tasmota devices (auto-detects network)
  152. const startScan = async () => {
  153. setIsScanning(true);
  154. setDiscoveredDevices([]);
  155. setScanProgress({ scanned: 0, total: 0 });
  156. setError(null);
  157. try {
  158. await api.startTasmotaScan();
  159. // Poll function to fetch status and devices
  160. const pollStatus = async () => {
  161. try {
  162. const status = await api.getTasmotaScanStatus();
  163. setScanProgress({ scanned: status.scanned, total: status.total });
  164. const devices = await api.getDiscoveredTasmotaDevices();
  165. setDiscoveredDevices(devices);
  166. if (!status.running) {
  167. setIsScanning(false);
  168. if (scanPollRef.current) {
  169. clearInterval(scanPollRef.current);
  170. scanPollRef.current = null;
  171. }
  172. }
  173. } catch (e) {
  174. console.error('Polling error:', e);
  175. }
  176. };
  177. // Poll immediately, then every 500ms
  178. await pollStatus();
  179. scanPollRef.current = setInterval(pollStatus, 500);
  180. } catch (err) {
  181. setIsScanning(false);
  182. const errorMsg = err instanceof Error ? err.message : (typeof err === 'string' ? err : JSON.stringify(err));
  183. setError(errorMsg || t('smartPlugs.failedToStartScan'));
  184. }
  185. };
  186. // Stop scanning
  187. const stopScan = async () => {
  188. try {
  189. await api.stopTasmotaScan();
  190. } catch {
  191. // Ignore stop errors
  192. }
  193. setIsScanning(false);
  194. if (scanPollRef.current) {
  195. clearInterval(scanPollRef.current);
  196. scanPollRef.current = null;
  197. }
  198. };
  199. // Select a discovered device
  200. const selectDevice = (device: DiscoveredTasmotaDevice) => {
  201. setIpAddress(device.ip_address);
  202. setName(device.name);
  203. setTestResult(null);
  204. };
  205. // Test connection mutation
  206. const testMutation = useMutation({
  207. mutationFn: () => api.testSmartPlugConnection(ipAddress, username || null, password || null),
  208. onSuccess: (result) => {
  209. setTestResult(result);
  210. setError(null);
  211. // Auto-fill name from device if empty
  212. if (!name && result.device_name) {
  213. setName(result.device_name);
  214. }
  215. },
  216. onError: (err: Error) => {
  217. setTestResult(null);
  218. setError(err.message);
  219. },
  220. });
  221. // Create mutation
  222. const createMutation = useMutation({
  223. mutationFn: (data: SmartPlugCreate) => api.createSmartPlug(data),
  224. onSuccess: () => {
  225. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  226. // Also invalidate printer card HA entity queries
  227. queryClient.invalidateQueries({ queryKey: ['smartPlugsByPrinter'] });
  228. onClose();
  229. },
  230. onError: (err: Error) => {
  231. setError(err.message);
  232. },
  233. });
  234. // Update mutation
  235. const updateMutation = useMutation({
  236. mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug!.id, data),
  237. onSuccess: () => {
  238. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  239. // Also invalidate printer card HA entity queries
  240. queryClient.invalidateQueries({ queryKey: ['smartPlugsByPrinter'] });
  241. onClose();
  242. },
  243. onError: (err: Error) => {
  244. setError(err.message);
  245. },
  246. });
  247. // For Tasmota plugs, only one per printer (physical device)
  248. // For HA scripts, allow multiple per printer
  249. const availablePrinters = printers?.filter(p => {
  250. if (plugType === 'tasmota') {
  251. const hasTasmotaPlug = existingPlugs?.some(
  252. ep => ep.printer_id === p.id && ep.id !== plug?.id && ep.plug_type === 'tasmota'
  253. );
  254. return !hasTasmotaPlug;
  255. }
  256. // HA scripts can have multiple per printer
  257. return true;
  258. });
  259. const handleSubmit = (e: React.FormEvent) => {
  260. e.preventDefault();
  261. setError(null);
  262. if (!name.trim()) {
  263. setError(t('smartPlugs.nameRequired'));
  264. return;
  265. }
  266. if (plugType === 'tasmota' && !ipAddress.trim()) {
  267. setError('IP address is required for Tasmota plugs');
  268. return;
  269. }
  270. if (plugType === 'homeassistant' && !haEntityId) {
  271. setError(t('smartPlugs.entityRequired'));
  272. return;
  273. }
  274. if (plugType === 'mqtt') {
  275. // Check that at least one topic is configured (path is optional)
  276. const hasPower = mqttPowerTopic.trim();
  277. const hasEnergy = mqttEnergyTopic.trim();
  278. const hasState = mqttStateTopic.trim();
  279. if (!hasPower && !hasEnergy && !hasState) {
  280. setError(t('smartPlugs.mqttTopicRequired'));
  281. return;
  282. }
  283. }
  284. const data = {
  285. name: name.trim(),
  286. plug_type: plugType,
  287. ip_address: plugType === 'tasmota' ? ipAddress.trim() : null,
  288. ha_entity_id: plugType === 'homeassistant' ? haEntityId : null,
  289. // HA energy sensor entities (optional)
  290. ha_power_entity: plugType === 'homeassistant' ? (haPowerEntity || null) : null,
  291. ha_energy_today_entity: plugType === 'homeassistant' ? (haEnergyTodayEntity || null) : null,
  292. ha_energy_total_entity: plugType === 'homeassistant' ? (haEnergyTotalEntity || null) : null,
  293. // MQTT power fields
  294. mqtt_power_topic: plugType === 'mqtt' ? (mqttPowerTopic.trim() || null) : null,
  295. mqtt_power_path: plugType === 'mqtt' ? (mqttPowerPath.trim() || null) : null,
  296. mqtt_power_multiplier: plugType === 'mqtt' ? (parseFloat(mqttPowerMultiplier) || 1) : 1,
  297. // MQTT energy fields
  298. mqtt_energy_topic: plugType === 'mqtt' ? (mqttEnergyTopic.trim() || null) : null,
  299. mqtt_energy_path: plugType === 'mqtt' ? (mqttEnergyPath.trim() || null) : null,
  300. mqtt_energy_multiplier: plugType === 'mqtt' ? (parseFloat(mqttEnergyMultiplier) || 1) : 1,
  301. // MQTT state fields
  302. mqtt_state_topic: plugType === 'mqtt' ? (mqttStateTopic.trim() || null) : null,
  303. mqtt_state_path: plugType === 'mqtt' ? (mqttStatePath.trim() || null) : null,
  304. mqtt_state_on_value: plugType === 'mqtt' ? (mqttStateOnValue.trim() || null) : null,
  305. username: plugType === 'tasmota' ? (username.trim() || null) : null,
  306. password: plugType === 'tasmota' ? (password.trim() || null) : null,
  307. printer_id: printerId,
  308. // Power alerts
  309. power_alert_enabled: powerAlertEnabled,
  310. power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,
  311. power_alert_low: powerAlertLow ? parseFloat(powerAlertLow) : null,
  312. // Schedule
  313. schedule_enabled: scheduleEnabled,
  314. schedule_on_time: scheduleOnTime || null,
  315. schedule_off_time: scheduleOffTime || null,
  316. // Visibility
  317. show_in_switchbar: showInSwitchbar,
  318. show_on_printer_card: showOnPrinterCard,
  319. };
  320. if (isEditing) {
  321. updateMutation.mutate(data);
  322. } else {
  323. createMutation.mutate(data);
  324. }
  325. };
  326. const isPending = createMutation.isPending || updateMutation.isPending;
  327. return (
  328. <div
  329. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  330. onClick={onClose}
  331. >
  332. <div
  333. className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md max-h-[90vh] flex flex-col"
  334. onClick={(e) => e.stopPropagation()}
  335. >
  336. {/* Header */}
  337. <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary flex-shrink-0">
  338. <h2 className="text-lg font-semibold text-white">
  339. {isEditing ? t('smartPlugs.editTitle') : t('smartPlugs.addTitle')}
  340. </h2>
  341. <button
  342. onClick={onClose}
  343. className="text-bambu-gray hover:text-white transition-colors"
  344. >
  345. <X className="w-5 h-5" />
  346. </button>
  347. </div>
  348. {/* Form */}
  349. <form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto">
  350. {error && (
  351. <div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
  352. {error}
  353. </div>
  354. )}
  355. {/* Plug Type Selector - only show when not editing */}
  356. {!isEditing && (
  357. <div className="flex gap-2 mb-2">
  358. <button
  359. type="button"
  360. onClick={() => {
  361. setPlugType('tasmota');
  362. setTestResult(null);
  363. setError(null);
  364. }}
  365. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
  366. plugType === 'tasmota'
  367. ? 'bg-bambu-green text-white'
  368. : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
  369. }`}
  370. >
  371. <Plug className="w-4 h-4" />
  372. Tasmota
  373. </button>
  374. <button
  375. type="button"
  376. onClick={() => {
  377. setPlugType('homeassistant');
  378. setTestResult(null);
  379. setError(null);
  380. }}
  381. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
  382. plugType === 'homeassistant'
  383. ? 'bg-bambu-green text-white'
  384. : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
  385. }`}
  386. >
  387. <Home className="w-4 h-4" />
  388. HA
  389. </button>
  390. <button
  391. type="button"
  392. onClick={() => {
  393. setPlugType('mqtt');
  394. setTestResult(null);
  395. setError(null);
  396. }}
  397. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
  398. plugType === 'mqtt'
  399. ? 'bg-bambu-green text-white'
  400. : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
  401. }`}
  402. >
  403. <Radio className="w-4 h-4" />
  404. MQTT
  405. </button>
  406. </div>
  407. )}
  408. {/* Discovery Section - only show when not editing and Tasmota is selected */}
  409. {!isEditing && plugType === 'tasmota' && (
  410. <div className="space-y-3">
  411. {/* Scan button - auto-detects network */}
  412. {isScanning ? (
  413. <Button type="button" variant="secondary" onClick={stopScan} className="w-full">
  414. <X className="w-4 h-4" />
  415. {t('smartPlugs.stopScanning')}
  416. </Button>
  417. ) : (
  418. <Button type="button" variant="primary" onClick={startScan} className="w-full">
  419. <Search className="w-4 h-4" />
  420. {t('smartPlugs.discoverTasmota')}
  421. </Button>
  422. )}
  423. {/* Progress bar */}
  424. {isScanning && scanProgress.total > 0 && (
  425. <div className="space-y-1">
  426. <div className="flex justify-between text-xs text-bambu-gray">
  427. <span>{t('smartPlugs.addSmartPlug.scanningNetwork')}</span>
  428. <span>{scanProgress.scanned} / {scanProgress.total}</span>
  429. </div>
  430. <div className="w-full bg-bambu-dark-tertiary rounded-full h-2">
  431. <div
  432. className="bg-bambu-green h-2 rounded-full transition-all duration-300"
  433. style={{ width: `${(scanProgress.scanned / scanProgress.total) * 100}%` }}
  434. />
  435. </div>
  436. </div>
  437. )}
  438. {/* Discovered devices */}
  439. {discoveredDevices.length > 0 && (
  440. <div className="space-y-2">
  441. <p className="text-xs text-bambu-gray">{t('smartPlugs.foundDevices', { count: discoveredDevices.length })}</p>
  442. <div className="max-h-40 overflow-y-auto space-y-1">
  443. {discoveredDevices.map((device) => (
  444. <button
  445. key={device.ip_address}
  446. type="button"
  447. onClick={() => selectDevice(device)}
  448. 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"
  449. >
  450. <div className="flex items-center gap-2">
  451. <Plug className="w-4 h-4 text-bambu-green" />
  452. <div>
  453. <p className="text-sm text-white">{device.name}</p>
  454. <p className="text-xs text-bambu-gray">{device.ip_address}</p>
  455. </div>
  456. </div>
  457. {device.state && (
  458. <span className={`flex items-center gap-1 text-xs ${
  459. device.state === 'ON' ? 'text-bambu-green' : 'text-bambu-gray'
  460. }`}>
  461. <Power className="w-3 h-3" />
  462. {device.state}
  463. </span>
  464. )}
  465. </button>
  466. ))}
  467. </div>
  468. </div>
  469. )}
  470. {!isScanning && discoveredDevices.length === 0 && scanProgress.total > 0 && (
  471. <p className="text-xs text-bambu-gray text-center py-2">
  472. {t('smartPlugs.noDevicesFound')}
  473. </p>
  474. )}
  475. </div>
  476. )}
  477. {/* Home Assistant Entity Selector - only show when HA is selected */}
  478. {plugType === 'homeassistant' && (
  479. <div className="space-y-3">
  480. {/* HA not configured */}
  481. {!haConfigured && (
  482. <div className="space-y-3">
  483. <div className="p-3 bg-yellow-500/20 border border-yellow-500/50 rounded-lg text-sm text-yellow-400">
  484. {t('smartPlugs.haNotConfigured')}{' '}
  485. <span className="font-medium">{t('smartPlugs.haSettingsPath')}</span>
  486. </div>
  487. <div>
  488. <label className="block text-sm text-bambu-gray mb-1 opacity-50">{t('smartPlugs.selectEntity')}</label>
  489. <select
  490. disabled
  491. 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"
  492. >
  493. <option>{t('smartPlugs.addSmartPlug.chooseEntity')}</option>
  494. </select>
  495. </div>
  496. </div>
  497. )}
  498. {/* HA configured - show loading/entities */}
  499. {haConfigured && (
  500. <>
  501. {haEntitiesLoading && (
  502. <div className="flex items-center justify-center py-4 text-bambu-gray">
  503. <Loader2 className="w-5 h-5 animate-spin mr-2" />
  504. {t('smartPlugs.loadingEntities')}
  505. </div>
  506. )}
  507. {haEntitiesError && (
  508. <div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
  509. {t('smartPlugs.failedToLoadEntities', { error: (haEntitiesError as Error).message })}
  510. </div>
  511. )}
  512. {/* Searchable Entity Dropdown */}
  513. {(() => {
  514. // Filter out entities already configured (except current plug when editing)
  515. const configuredEntityIds = existingPlugs
  516. ?.filter(p => p.ha_entity_id && p.id !== plug?.id)
  517. .map(p => p.ha_entity_id) || [];
  518. const availableEntities = (haEntities || []).filter(e => !configuredEntityIds.includes(e.entity_id));
  519. const selectedEntity = haEntities?.find(e => e.entity_id === haEntityId);
  520. return (
  521. <div ref={entityDropdownRef} className="relative">
  522. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.selectEntity')}</label>
  523. <div className="relative">
  524. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  525. <input
  526. type="text"
  527. value={isEntityDropdownOpen ? haEntitySearch : (selectedEntity ? `${selectedEntity.friendly_name} (${selectedEntity.entity_id})` : '')}
  528. onChange={(e) => {
  529. setHaEntitySearch(e.target.value);
  530. if (!isEntityDropdownOpen) setIsEntityDropdownOpen(true);
  531. }}
  532. onFocus={() => {
  533. setIsEntityDropdownOpen(true);
  534. setHaEntitySearch('');
  535. }}
  536. placeholder={t('smartPlugs.addSmartPlug.placeholders.searchEntities')}
  537. 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"
  538. />
  539. {haEntityId && !isEntityDropdownOpen && (
  540. <button
  541. type="button"
  542. onClick={() => {
  543. setHaEntityId('');
  544. setHaEntitySearch('');
  545. }}
  546. className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded"
  547. >
  548. <X className="w-4 h-4 text-bambu-gray hover:text-white" />
  549. </button>
  550. )}
  551. {haEntitiesLoading && (
  552. <Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray animate-spin" />
  553. )}
  554. </div>
  555. {/* Dropdown */}
  556. {isEntityDropdownOpen && (
  557. <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">
  558. {haEntitiesLoading && (
  559. <div className="px-3 py-2 text-sm text-bambu-gray flex items-center gap-2">
  560. <Loader2 className="w-4 h-4 animate-spin" />
  561. {t('smartPlugs.loading')}
  562. </div>
  563. )}
  564. {!haEntitiesLoading && availableEntities.length === 0 && (
  565. <div className="px-3 py-2 text-sm text-bambu-gray">
  566. {debouncedSearch
  567. ? t('smartPlugs.noEntitiesMatching', { search: debouncedSearch })
  568. : t('smartPlugs.noEntitiesAvailable')}
  569. </div>
  570. )}
  571. {!haEntitiesLoading && availableEntities.map((entity) => (
  572. <button
  573. key={entity.entity_id}
  574. type="button"
  575. onClick={() => {
  576. setHaEntityId(entity.entity_id);
  577. setIsEntityDropdownOpen(false);
  578. setHaEntitySearch('');
  579. // Auto-fill name
  580. if (!name) {
  581. setName(entity.friendly_name);
  582. }
  583. }}
  584. className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary transition-colors ${
  585. entity.entity_id === haEntityId ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'
  586. }`}
  587. >
  588. <div className="font-medium">{entity.friendly_name}</div>
  589. <div className="text-xs text-bambu-gray flex items-center justify-between">
  590. <span>{entity.entity_id}</span>
  591. <span className={entity.state === 'on' ? 'text-bambu-green' : ''}>{entity.state}</span>
  592. </div>
  593. </button>
  594. ))}
  595. </div>
  596. )}
  597. <p className="text-xs text-bambu-gray mt-1">
  598. {debouncedSearch
  599. ? t('smartPlugs.searchingEntities', { count: availableEntities.length })
  600. : t('smartPlugs.showingEntities', { count: availableEntities.length })}
  601. </p>
  602. </div>
  603. );
  604. })()}
  605. {/* Energy Monitoring Section (Optional) */}
  606. {haEntityId && haSensorEntities && haSensorEntities.length > 0 && (
  607. <div className="border-t border-bambu-dark-tertiary pt-4 mt-4 space-y-3">
  608. <div>
  609. <p className="text-white font-medium mb-1">{t('smartPlugs.energyMonitoringOptional')}</p>
  610. <p className="text-xs text-bambu-gray mb-3">
  611. {t('smartPlugs.energyMonitoringHint')}
  612. </p>
  613. </div>
  614. {/* Power Sensor (W) */}
  615. {(() => {
  616. const powerSensors = haSensorEntities.filter(s =>
  617. s.unit_of_measurement === 'W' || s.unit_of_measurement === 'kW' || s.unit_of_measurement === 'mW'
  618. );
  619. const filteredPowerSensors = powerSensorSearch
  620. ? powerSensors.filter(s =>
  621. s.entity_id.toLowerCase().includes(powerSensorSearch.toLowerCase()) ||
  622. s.friendly_name.toLowerCase().includes(powerSensorSearch.toLowerCase())
  623. )
  624. : powerSensors;
  625. const selectedPowerSensor = haSensorEntities.find(s => s.entity_id === haPowerEntity);
  626. return (
  627. <div ref={powerDropdownRef} className="relative">
  628. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.powerSensorW')}</label>
  629. <div className="relative">
  630. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  631. <input
  632. type="text"
  633. value={isPowerDropdownOpen ? powerSensorSearch : (selectedPowerSensor ? `${selectedPowerSensor.friendly_name} (${selectedPowerSensor.state} ${selectedPowerSensor.unit_of_measurement})` : '')}
  634. onChange={(e) => {
  635. setPowerSensorSearch(e.target.value);
  636. if (!isPowerDropdownOpen) setIsPowerDropdownOpen(true);
  637. }}
  638. onFocus={() => {
  639. setIsPowerDropdownOpen(true);
  640. setPowerSensorSearch('');
  641. }}
  642. placeholder={t('smartPlugs.addSmartPlug.placeholders.searchPowerSensors')}
  643. 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"
  644. />
  645. {haPowerEntity && !isPowerDropdownOpen && (
  646. <button
  647. type="button"
  648. onClick={() => {
  649. setHaPowerEntity('');
  650. setPowerSensorSearch('');
  651. }}
  652. className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded"
  653. >
  654. <X className="w-4 h-4 text-bambu-gray hover:text-white" />
  655. </button>
  656. )}
  657. </div>
  658. {isPowerDropdownOpen && (
  659. <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">
  660. <button
  661. type="button"
  662. onClick={() => {
  663. setHaPowerEntity('');
  664. setIsPowerDropdownOpen(false);
  665. setPowerSensorSearch('');
  666. }}
  667. className="w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary"
  668. >
  669. {t('smartPlugs.none')}
  670. </button>
  671. {filteredPowerSensors.map((sensor) => (
  672. <button
  673. key={sensor.entity_id}
  674. type="button"
  675. onClick={() => {
  676. setHaPowerEntity(sensor.entity_id);
  677. setIsPowerDropdownOpen(false);
  678. setPowerSensorSearch('');
  679. }}
  680. className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
  681. sensor.entity_id === haPowerEntity ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'
  682. }`}
  683. >
  684. <div className="font-medium">{sensor.friendly_name}</div>
  685. <div className="text-xs text-bambu-gray">{sensor.entity_id} • {sensor.state} {sensor.unit_of_measurement}</div>
  686. </button>
  687. ))}
  688. {filteredPowerSensors.length === 0 && (
  689. <div className="px-3 py-2 text-sm text-bambu-gray">{t('smartPlugs.noMatchingSensors')}</div>
  690. )}
  691. </div>
  692. )}
  693. </div>
  694. );
  695. })()}
  696. {/* Energy Today (kWh) */}
  697. {(() => {
  698. const energySensors = haSensorEntities.filter(s =>
  699. s.unit_of_measurement === 'kWh' || s.unit_of_measurement === 'Wh' || s.unit_of_measurement === 'MWh'
  700. );
  701. const filteredEnergySensors = energyTodaySearch
  702. ? energySensors.filter(s =>
  703. s.entity_id.toLowerCase().includes(energyTodaySearch.toLowerCase()) ||
  704. s.friendly_name.toLowerCase().includes(energyTodaySearch.toLowerCase())
  705. )
  706. : energySensors;
  707. const selectedSensor = haSensorEntities.find(s => s.entity_id === haEnergyTodayEntity);
  708. return (
  709. <div ref={energyTodayDropdownRef} className="relative">
  710. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.energyTodayKwh')}</label>
  711. <div className="relative">
  712. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  713. <input
  714. type="text"
  715. value={isEnergyTodayDropdownOpen ? energyTodaySearch : (selectedSensor ? `${selectedSensor.friendly_name} (${selectedSensor.state} ${selectedSensor.unit_of_measurement})` : '')}
  716. onChange={(e) => {
  717. setEnergyTodaySearch(e.target.value);
  718. if (!isEnergyTodayDropdownOpen) setIsEnergyTodayDropdownOpen(true);
  719. }}
  720. onFocus={() => {
  721. setIsEnergyTodayDropdownOpen(true);
  722. setEnergyTodaySearch('');
  723. }}
  724. placeholder={t('smartPlugs.addSmartPlug.placeholders.searchEnergySensors')}
  725. 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"
  726. />
  727. {haEnergyTodayEntity && !isEnergyTodayDropdownOpen && (
  728. <button
  729. type="button"
  730. onClick={() => {
  731. setHaEnergyTodayEntity('');
  732. setEnergyTodaySearch('');
  733. }}
  734. className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded"
  735. >
  736. <X className="w-4 h-4 text-bambu-gray hover:text-white" />
  737. </button>
  738. )}
  739. </div>
  740. {isEnergyTodayDropdownOpen && (
  741. <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">
  742. <button
  743. type="button"
  744. onClick={() => {
  745. setHaEnergyTodayEntity('');
  746. setIsEnergyTodayDropdownOpen(false);
  747. setEnergyTodaySearch('');
  748. }}
  749. className="w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary"
  750. >
  751. {t('smartPlugs.none')}
  752. </button>
  753. {filteredEnergySensors.map((sensor) => (
  754. <button
  755. key={sensor.entity_id}
  756. type="button"
  757. onClick={() => {
  758. setHaEnergyTodayEntity(sensor.entity_id);
  759. setIsEnergyTodayDropdownOpen(false);
  760. setEnergyTodaySearch('');
  761. }}
  762. className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
  763. sensor.entity_id === haEnergyTodayEntity ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'
  764. }`}
  765. >
  766. <div className="font-medium">{sensor.friendly_name}</div>
  767. <div className="text-xs text-bambu-gray">{sensor.entity_id} • {sensor.state} {sensor.unit_of_measurement}</div>
  768. </button>
  769. ))}
  770. {filteredEnergySensors.length === 0 && (
  771. <div className="px-3 py-2 text-sm text-bambu-gray">{t('smartPlugs.noMatchingSensors')}</div>
  772. )}
  773. </div>
  774. )}
  775. </div>
  776. );
  777. })()}
  778. {/* Total Energy (kWh) */}
  779. {(() => {
  780. const energySensors = haSensorEntities.filter(s =>
  781. s.unit_of_measurement === 'kWh' || s.unit_of_measurement === 'Wh' || s.unit_of_measurement === 'MWh'
  782. );
  783. const filteredEnergySensors = energyTotalSearch
  784. ? energySensors.filter(s =>
  785. s.entity_id.toLowerCase().includes(energyTotalSearch.toLowerCase()) ||
  786. s.friendly_name.toLowerCase().includes(energyTotalSearch.toLowerCase())
  787. )
  788. : energySensors;
  789. const selectedSensor = haSensorEntities.find(s => s.entity_id === haEnergyTotalEntity);
  790. return (
  791. <div ref={energyTotalDropdownRef} className="relative">
  792. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.totalEnergyKwh')}</label>
  793. <div className="relative">
  794. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  795. <input
  796. type="text"
  797. value={isEnergyTotalDropdownOpen ? energyTotalSearch : (selectedSensor ? `${selectedSensor.friendly_name} (${selectedSensor.state} ${selectedSensor.unit_of_measurement})` : '')}
  798. onChange={(e) => {
  799. setEnergyTotalSearch(e.target.value);
  800. if (!isEnergyTotalDropdownOpen) setIsEnergyTotalDropdownOpen(true);
  801. }}
  802. onFocus={() => {
  803. setIsEnergyTotalDropdownOpen(true);
  804. setEnergyTotalSearch('');
  805. }}
  806. placeholder={t('smartPlugs.addSmartPlug.placeholders.searchEnergySensors')}
  807. 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"
  808. />
  809. {haEnergyTotalEntity && !isEnergyTotalDropdownOpen && (
  810. <button
  811. type="button"
  812. onClick={() => {
  813. setHaEnergyTotalEntity('');
  814. setEnergyTotalSearch('');
  815. }}
  816. className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded"
  817. >
  818. <X className="w-4 h-4 text-bambu-gray hover:text-white" />
  819. </button>
  820. )}
  821. </div>
  822. {isEnergyTotalDropdownOpen && (
  823. <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">
  824. <button
  825. type="button"
  826. onClick={() => {
  827. setHaEnergyTotalEntity('');
  828. setIsEnergyTotalDropdownOpen(false);
  829. setEnergyTotalSearch('');
  830. }}
  831. className="w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary"
  832. >
  833. {t('smartPlugs.none')}
  834. </button>
  835. {filteredEnergySensors.map((sensor) => (
  836. <button
  837. key={sensor.entity_id}
  838. type="button"
  839. onClick={() => {
  840. setHaEnergyTotalEntity(sensor.entity_id);
  841. setIsEnergyTotalDropdownOpen(false);
  842. setEnergyTotalSearch('');
  843. }}
  844. className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
  845. sensor.entity_id === haEnergyTotalEntity ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'
  846. }`}
  847. >
  848. <div className="font-medium">{sensor.friendly_name}</div>
  849. <div className="text-xs text-bambu-gray">{sensor.entity_id} • {sensor.state} {sensor.unit_of_measurement}</div>
  850. </button>
  851. ))}
  852. {filteredEnergySensors.length === 0 && (
  853. <div className="px-3 py-2 text-sm text-bambu-gray">{t('smartPlugs.noMatchingSensors')}</div>
  854. )}
  855. </div>
  856. )}
  857. </div>
  858. );
  859. })()}
  860. </div>
  861. )}
  862. </>
  863. )}
  864. </div>
  865. )}
  866. {/* MQTT Configuration - only show when MQTT is selected */}
  867. {plugType === 'mqtt' && (
  868. <div className="space-y-3">
  869. {/* MQTT broker not configured */}
  870. {!settings?.mqtt_broker && (
  871. <div className="p-3 bg-yellow-500/20 border border-yellow-500/50 rounded-lg text-sm text-yellow-400">
  872. {t('smartPlugs.mqttNotConfigured')}{' '}
  873. <span className="font-medium">{t('smartPlugs.mqttSettingsPath')}</span>
  874. {' '}{t('smartPlugs.mqttNotConfiguredSuffix')}
  875. </div>
  876. )}
  877. {/* MQTT broker configured - show fields */}
  878. {settings?.mqtt_broker && (
  879. <>
  880. <div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg text-sm text-blue-300">
  881. <p className="font-medium mb-1">{t('smartPlugs.monitorOnly')}</p>
  882. <p className="text-xs opacity-80">
  883. {t('smartPlugs.mqttMonitorOnlyDescription')}
  884. </p>
  885. </div>
  886. {/* Power Section */}
  887. <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
  888. <p className="text-white font-medium text-sm">{t('smartPlugs.powerMonitoring')}</p>
  889. <div>
  890. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.topic')}</label>
  891. <input
  892. type="text"
  893. value={mqttPowerTopic}
  894. onChange={(e) => setMqttPowerTopic(e.target.value)}
  895. placeholder="zigbee2mqtt/shelly-working-room"
  896. 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"
  897. />
  898. </div>
  899. <div className="grid grid-cols-2 gap-3">
  900. <div>
  901. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.jsonPath')}</label>
  902. <input
  903. type="text"
  904. value={mqttPowerPath}
  905. onChange={(e) => setMqttPowerPath(e.target.value)}
  906. placeholder="power_l1"
  907. 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"
  908. />
  909. </div>
  910. <div>
  911. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.multiplier')}</label>
  912. <input
  913. type="text"
  914. value={mqttPowerMultiplier}
  915. onChange={(e) => setMqttPowerMultiplier(e.target.value)}
  916. placeholder="1"
  917. 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"
  918. />
  919. </div>
  920. </div>
  921. <p className="text-xs text-bambu-gray" style={{ whiteSpace: 'pre-line' }}>
  922. {t('smartPlugs.mqttPowerHint')}
  923. </p>
  924. </div>
  925. {/* Energy Section */}
  926. <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
  927. <p className="text-white font-medium text-sm">{t('smartPlugs.energyMonitoring')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></p>
  928. <div>
  929. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.topic')}</label>
  930. <input
  931. type="text"
  932. value={mqttEnergyTopic}
  933. onChange={(e) => setMqttEnergyTopic(e.target.value)}
  934. placeholder="Same as power topic, or different"
  935. 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"
  936. />
  937. </div>
  938. <div className="grid grid-cols-2 gap-3">
  939. <div>
  940. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.jsonPath')}</label>
  941. <input
  942. type="text"
  943. value={mqttEnergyPath}
  944. onChange={(e) => setMqttEnergyPath(e.target.value)}
  945. placeholder="energy_l1"
  946. 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"
  947. />
  948. </div>
  949. <div>
  950. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.multiplier')}</label>
  951. <input
  952. type="text"
  953. value={mqttEnergyMultiplier}
  954. onChange={(e) => setMqttEnergyMultiplier(e.target.value)}
  955. placeholder="1"
  956. 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"
  957. />
  958. </div>
  959. </div>
  960. <p className="text-xs text-bambu-gray" style={{ whiteSpace: 'pre-line' }}>
  961. {t('smartPlugs.mqttEnergyHint')}
  962. </p>
  963. </div>
  964. {/* State Section */}
  965. <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
  966. <p className="text-white font-medium text-sm">{t('smartPlugs.stateMonitoring')} <span className="text-bambu-gray font-normal">({t('smartPlugs.optional')})</span></p>
  967. <div>
  968. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.topic')}</label>
  969. <input
  970. type="text"
  971. value={mqttStateTopic}
  972. onChange={(e) => setMqttStateTopic(e.target.value)}
  973. placeholder="Same as power topic, or different"
  974. 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"
  975. />
  976. </div>
  977. <div className="grid grid-cols-2 gap-3">
  978. <div>
  979. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.jsonPath')}</label>
  980. <input
  981. type="text"
  982. value={mqttStatePath}
  983. onChange={(e) => setMqttStatePath(e.target.value)}
  984. placeholder="state_l1"
  985. 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"
  986. />
  987. </div>
  988. <div>
  989. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.onValue')}</label>
  990. <input
  991. type="text"
  992. value={mqttStateOnValue}
  993. onChange={(e) => setMqttStateOnValue(e.target.value)}
  994. placeholder={t('smartPlugs.addSmartPlug.placeholders.mqttStateOnValue')}
  995. 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"
  996. />
  997. </div>
  998. </div>
  999. <p className="text-xs text-bambu-gray" style={{ whiteSpace: 'pre-line' }}>
  1000. {t('smartPlugs.mqttStateHint')}
  1001. </p>
  1002. </div>
  1003. </>
  1004. )}
  1005. </div>
  1006. )}
  1007. {/* IP Address - only show for Tasmota */}
  1008. {plugType === 'tasmota' && (
  1009. <div>
  1010. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.ipAddress')}</label>
  1011. <div className="flex gap-2">
  1012. <input
  1013. type="text"
  1014. value={ipAddress}
  1015. onChange={(e) => {
  1016. setIpAddress(e.target.value);
  1017. setTestResult(null);
  1018. }}
  1019. placeholder="192.168.1.100"
  1020. 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"
  1021. />
  1022. <Button
  1023. type="button"
  1024. variant="secondary"
  1025. onClick={() => testMutation.mutate()}
  1026. disabled={!ipAddress.trim() || testMutation.isPending}
  1027. >
  1028. {testMutation.isPending ? (
  1029. <Loader2 className="w-4 h-4 animate-spin" />
  1030. ) : (
  1031. <Wifi className="w-4 h-4" />
  1032. )}
  1033. {t('smartPlugs.test')}
  1034. </Button>
  1035. </div>
  1036. </div>
  1037. )}
  1038. {/* Test Result - only show for Tasmota */}
  1039. {plugType === 'tasmota' && testResult && (
  1040. <div className={`p-3 rounded-lg flex items-center gap-2 ${
  1041. testResult.success
  1042. ? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'
  1043. : 'bg-red-500/20 border border-red-500/50 text-red-400'
  1044. }`}>
  1045. {testResult.success ? (
  1046. <>
  1047. <CheckCircle className="w-5 h-5" />
  1048. <div>
  1049. <p className="font-medium">{t('smartPlugs.connectedResult')}</p>
  1050. <p className="text-sm opacity-80">
  1051. {testResult.device_name && t('smartPlugs.deviceLabel', { name: testResult.device_name })}
  1052. {t('smartPlugs.stateLabel', { state: testResult.state })}
  1053. </p>
  1054. </div>
  1055. </>
  1056. ) : (
  1057. <>
  1058. <WifiOff className="w-5 h-5" />
  1059. <span>{t('smartPlugs.addSmartPlug.connectionFailed')}</span>
  1060. </>
  1061. )}
  1062. </div>
  1063. )}
  1064. {/* Name */}
  1065. <div>
  1066. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.nameLabel')}</label>
  1067. <input
  1068. type="text"
  1069. value={name}
  1070. onChange={(e) => setName(e.target.value)}
  1071. placeholder={t('smartPlugs.addSmartPlug.placeholders.plugName')}
  1072. 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"
  1073. />
  1074. </div>
  1075. {/* Authentication (optional) - only show for Tasmota */}
  1076. {plugType === 'tasmota' && (
  1077. <>
  1078. <div className="grid grid-cols-2 gap-3">
  1079. <div>
  1080. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.username')}</label>
  1081. <input
  1082. type="text"
  1083. value={username}
  1084. onChange={(e) => setUsername(e.target.value)}
  1085. placeholder="admin"
  1086. 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"
  1087. />
  1088. </div>
  1089. <div>
  1090. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.password')}</label>
  1091. <input
  1092. type="password"
  1093. value={password}
  1094. onChange={(e) => setPassword(e.target.value)}
  1095. placeholder="********"
  1096. 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"
  1097. />
  1098. </div>
  1099. </div>
  1100. <p className="text-xs text-bambu-gray -mt-2">
  1101. {t('smartPlugs.authHint')}
  1102. </p>
  1103. </>
  1104. )}
  1105. {/* Link to Printer - not shown for MQTT plugs (monitor-only) */}
  1106. {plugType !== 'mqtt' && (
  1107. <div>
  1108. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.linkToPrinter')}</label>
  1109. <select
  1110. value={printerId ?? ''}
  1111. onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
  1112. 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"
  1113. >
  1114. <option value="">{t('smartPlugs.noPrinter')}</option>
  1115. {availablePrinters?.map((p) => (
  1116. <option key={p.id} value={p.id}>
  1117. {p.name}
  1118. </option>
  1119. ))}
  1120. </select>
  1121. <p className="text-xs text-bambu-gray mt-1">
  1122. {t('smartPlugs.linkingDescription')}
  1123. </p>
  1124. </div>
  1125. )}
  1126. {/* Power Alerts */}
  1127. <div className="border-t border-bambu-dark-tertiary pt-4">
  1128. <div className="flex items-center justify-between mb-3">
  1129. <div className="flex items-center gap-2">
  1130. <Bell className="w-4 h-4 text-bambu-green" />
  1131. <span className="text-white font-medium">{t('smartPlugs.powerAlerts')}</span>
  1132. </div>
  1133. <label className="relative inline-flex items-center cursor-pointer">
  1134. <input
  1135. type="checkbox"
  1136. checked={powerAlertEnabled}
  1137. onChange={(e) => setPowerAlertEnabled(e.target.checked)}
  1138. className="sr-only peer"
  1139. />
  1140. <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>
  1141. </label>
  1142. </div>
  1143. {powerAlertEnabled && (
  1144. <div className="space-y-3">
  1145. <div className="grid grid-cols-2 gap-3">
  1146. <div>
  1147. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.alertAbove')}</label>
  1148. <input
  1149. type="number"
  1150. value={powerAlertHigh}
  1151. onChange={(e) => setPowerAlertHigh(e.target.value)}
  1152. placeholder="e.g. 200"
  1153. min="0"
  1154. max="5000"
  1155. 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"
  1156. />
  1157. </div>
  1158. <div>
  1159. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.alertBelow')}</label>
  1160. <input
  1161. type="number"
  1162. value={powerAlertLow}
  1163. onChange={(e) => setPowerAlertLow(e.target.value)}
  1164. placeholder="e.g. 10"
  1165. min="0"
  1166. max="5000"
  1167. 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"
  1168. />
  1169. </div>
  1170. </div>
  1171. <p className="text-xs text-bambu-gray">
  1172. {t('smartPlugs.alertDescription')}
  1173. </p>
  1174. </div>
  1175. )}
  1176. </div>
  1177. {/* Schedule - not shown for MQTT plugs (monitor-only) */}
  1178. {plugType !== 'mqtt' && (
  1179. <div className="border-t border-bambu-dark-tertiary pt-4">
  1180. <div className="flex items-center justify-between mb-3">
  1181. <div className="flex items-center gap-2">
  1182. <Clock className="w-4 h-4 text-bambu-green" />
  1183. <span className="text-white font-medium">{t('smartPlugs.dailySchedule')}</span>
  1184. </div>
  1185. <label className="relative inline-flex items-center cursor-pointer">
  1186. <input
  1187. type="checkbox"
  1188. checked={scheduleEnabled}
  1189. onChange={(e) => setScheduleEnabled(e.target.checked)}
  1190. className="sr-only peer"
  1191. />
  1192. <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>
  1193. </label>
  1194. </div>
  1195. {scheduleEnabled && (
  1196. <div className="space-y-3">
  1197. <div className="grid grid-cols-2 gap-3">
  1198. <div>
  1199. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.turnOnAt')}</label>
  1200. <input
  1201. type="time"
  1202. value={scheduleOnTime}
  1203. onChange={(e) => setScheduleOnTime(e.target.value)}
  1204. 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"
  1205. />
  1206. </div>
  1207. <div>
  1208. <label className="block text-sm text-bambu-gray mb-1">{t('smartPlugs.turnOffAt')}</label>
  1209. <input
  1210. type="time"
  1211. value={scheduleOffTime}
  1212. onChange={(e) => setScheduleOffTime(e.target.value)}
  1213. 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"
  1214. />
  1215. </div>
  1216. </div>
  1217. <p className="text-xs text-bambu-gray">
  1218. {t('smartPlugs.scheduleDescription')}
  1219. </p>
  1220. </div>
  1221. )}
  1222. </div>
  1223. )}
  1224. {/* Switchbar Visibility */}
  1225. <div className="border-t border-bambu-dark-tertiary pt-4">
  1226. <div className="flex items-center justify-between">
  1227. <div className="flex items-center gap-2">
  1228. <LayoutGrid className="w-4 h-4 text-bambu-green" />
  1229. <div>
  1230. <span className="text-white font-medium">{t('smartPlugs.showInSwitchbar')}</span>
  1231. <p className="text-xs text-bambu-gray">{t('smartPlugs.quickAccessSidebar')}</p>
  1232. </div>
  1233. </div>
  1234. <label className="relative inline-flex items-center cursor-pointer">
  1235. <input
  1236. type="checkbox"
  1237. checked={showInSwitchbar}
  1238. onChange={(e) => setShowInSwitchbar(e.target.checked)}
  1239. className="sr-only peer"
  1240. />
  1241. <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>
  1242. </label>
  1243. </div>
  1244. </div>
  1245. {/* Printer Card Visibility - only for HA entities */}
  1246. {plugType === 'homeassistant' && (
  1247. <div className="border-t border-bambu-dark-tertiary pt-4">
  1248. <div className="flex items-center justify-between">
  1249. <div className="flex items-center gap-2">
  1250. <Eye className="w-4 h-4 text-bambu-green" />
  1251. <div>
  1252. <span className="text-white font-medium">{t('smartPlugs.showOnPrinterCard')}</span>
  1253. <p className="text-xs text-bambu-gray">{t('smartPlugs.displayOnPrinterCard')}</p>
  1254. </div>
  1255. </div>
  1256. <label className="relative inline-flex items-center cursor-pointer">
  1257. <input
  1258. type="checkbox"
  1259. checked={showOnPrinterCard}
  1260. onChange={(e) => setShowOnPrinterCard(e.target.checked)}
  1261. className="sr-only peer"
  1262. />
  1263. <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>
  1264. </label>
  1265. </div>
  1266. </div>
  1267. )}
  1268. {/* Actions */}
  1269. <div className="flex gap-3 pt-2">
  1270. <Button
  1271. type="button"
  1272. variant="secondary"
  1273. onClick={onClose}
  1274. className="flex-1"
  1275. >
  1276. {t('smartPlugs.cancel')}
  1277. </Button>
  1278. <Button
  1279. type="submit"
  1280. disabled={isPending}
  1281. className="flex-1"
  1282. >
  1283. {isPending ? (
  1284. <Loader2 className="w-4 h-4 animate-spin" />
  1285. ) : (
  1286. <Save className="w-4 h-4" />
  1287. )}
  1288. {isEditing ? t('smartPlugs.save') : t('smartPlugs.add')}
  1289. </Button>
  1290. </div>
  1291. </form>
  1292. </div>
  1293. </div>
  1294. );
  1295. }