AddSmartPlugModal.tsx 67 KB

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