AddSmartPlugModal.tsx 65 KB

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