SmartPlugCard.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. import { useState } from 'react';
  2. import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
  3. import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home, Radio, Eye } from 'lucide-react';
  4. import { api } from '../api/client';
  5. import type { SmartPlug, SmartPlugUpdate } from '../api/client';
  6. import { Card, CardContent } from './Card';
  7. import { Button } from './Button';
  8. import { ConfirmModal } from './ConfirmModal';
  9. import { useToast } from '../contexts/ToastContext';
  10. interface SmartPlugCardProps {
  11. plug: SmartPlug;
  12. onEdit: (plug: SmartPlug) => void;
  13. }
  14. export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
  15. const queryClient = useQueryClient();
  16. const { showToast } = useToast();
  17. const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
  18. const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);
  19. const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
  20. const [isExpanded, setIsExpanded] = useState(false);
  21. // Fetch current status
  22. const { data: status, isLoading: statusLoading } = useQuery({
  23. queryKey: ['smart-plug-status', plug.id],
  24. queryFn: () => api.getSmartPlugStatus(plug.id),
  25. refetchInterval: 30000, // Refresh every 30 seconds
  26. });
  27. // Fetch printers for linking
  28. const { data: printers } = useQuery({
  29. queryKey: ['printers'],
  30. queryFn: api.getPrinters,
  31. });
  32. const linkedPrinter = printers?.find(p => p.id === plug.printer_id);
  33. // Control mutation with optimistic updates
  34. const controlMutation = useMutation({
  35. mutationFn: (action: 'on' | 'off' | 'toggle') => api.controlSmartPlug(plug.id, action),
  36. onMutate: async (action) => {
  37. // Cancel any outgoing refetches
  38. await queryClient.cancelQueries({ queryKey: ['smart-plug-status', plug.id] });
  39. // Snapshot the previous value
  40. const previousStatus = queryClient.getQueryData(['smart-plug-status', plug.id]);
  41. // Optimistically update to the new value
  42. const newState = action === 'on' ? 'ON' : action === 'off' ? 'OFF' : (status?.state === 'ON' ? 'OFF' : 'ON');
  43. queryClient.setQueryData(['smart-plug-status', plug.id], (old: typeof status) => ({
  44. ...old,
  45. state: newState,
  46. }));
  47. return { previousStatus };
  48. },
  49. onError: (_err, action, context) => {
  50. // Rollback on error
  51. if (context?.previousStatus) {
  52. queryClient.setQueryData(['smart-plug-status', plug.id], context.previousStatus);
  53. }
  54. showToast(`Failed to turn ${action} "${plug.name}"`, 'error');
  55. },
  56. onSettled: () => {
  57. // Refetch after a short delay to get actual state
  58. setTimeout(() => {
  59. queryClient.invalidateQueries({ queryKey: ['smart-plug-status', plug.id] });
  60. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  61. }, 1000);
  62. },
  63. });
  64. // Update mutation
  65. const updateMutation = useMutation({
  66. mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug.id, data),
  67. onSuccess: () => {
  68. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  69. // Also invalidate printer-specific smart plug queries to keep PrintersPage in sync
  70. if (plug.printer_id) {
  71. queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', plug.printer_id] });
  72. queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter', plug.printer_id] });
  73. }
  74. },
  75. });
  76. // Delete mutation
  77. const deleteMutation = useMutation({
  78. mutationFn: () => api.deleteSmartPlug(plug.id),
  79. onSuccess: () => {
  80. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  81. // Also invalidate printer card HA entity queries
  82. if (plug.printer_id) {
  83. queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter', plug.printer_id] });
  84. }
  85. },
  86. });
  87. const isOn = status?.state === 'ON';
  88. // For MQTT plugs, consider reachable if we have power data (even if backend says not reachable)
  89. const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power !== null && status?.energy?.power !== undefined);
  90. const isReachable = (status?.reachable ?? false) || hasMqttData;
  91. const isPending = controlMutation.isPending;
  92. // Generate admin URL with auto-login credentials (Tasmota only)
  93. const getAdminUrl = () => {
  94. if (plug.plug_type !== 'tasmota' || !plug.ip_address) return null;
  95. const ip = plug.ip_address;
  96. if (plug.username && plug.password) {
  97. // Use HTTP Basic Auth in URL for auto-login
  98. return `http://${encodeURIComponent(plug.username)}:${encodeURIComponent(plug.password)}@${ip}/`;
  99. }
  100. return `http://${ip}/`;
  101. };
  102. const adminUrl = getAdminUrl();
  103. return (
  104. <>
  105. <Card className="relative">
  106. <CardContent className="p-4">
  107. {/* Header Row */}
  108. <div className="flex items-start justify-between gap-2 mb-3">
  109. <div className="flex items-center gap-3 min-w-0 flex-1">
  110. <div className={`p-2 rounded-lg flex-shrink-0 ${
  111. plug.plug_type === 'mqtt'
  112. ? (isReachable ? 'bg-teal-500/20' : 'bg-red-500/20')
  113. : (isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20')
  114. }`}>
  115. {plug.plug_type === 'mqtt' ? (
  116. <Radio className={`w-5 h-5 ${isReachable ? 'text-teal-400' : 'text-red-400'}`} />
  117. ) : plug.plug_type === 'homeassistant' ? (
  118. <Home className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
  119. ) : (
  120. <Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
  121. )}
  122. </div>
  123. <div className="min-w-0">
  124. <h3 className="font-medium text-white truncate">{plug.name}</h3>
  125. <p
  126. className="text-sm text-bambu-gray truncate"
  127. title={plug.plug_type === 'mqtt' ? plug.mqtt_topic ?? undefined : plug.plug_type === 'homeassistant' ? plug.ha_entity_id ?? undefined : plug.ip_address ?? undefined}
  128. >
  129. {plug.plug_type === 'mqtt' ? plug.mqtt_topic : plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}
  130. </p>
  131. </div>
  132. </div>
  133. {/* Status indicator */}
  134. <div className="flex flex-col items-end gap-1 flex-shrink-0">
  135. {statusLoading ? (
  136. <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
  137. ) : plug.plug_type === 'mqtt' ? (
  138. /* MQTT plugs - show badge and checkmark when receiving data */
  139. <div className="flex items-center gap-1.5 text-sm whitespace-nowrap">
  140. <span className="px-1.5 py-0.5 bg-teal-500/20 text-teal-400 text-[10px] font-medium rounded flex-shrink-0">MQTT</span>
  141. {isReachable && <span className="text-status-ok">✓</span>}
  142. </div>
  143. ) : plug.plug_type === 'homeassistant' ? (
  144. <div className="flex items-center gap-1 text-sm">
  145. <span className="px-1 py-0.5 bg-blue-500/20 text-blue-400 text-[10px] font-medium rounded">HA</span>
  146. <span className={isReachable ? (isOn ? 'text-status-ok' : 'text-bambu-gray') : 'text-status-error'}>
  147. {isReachable ? (status?.state || '?') : 'Offline'}
  148. </span>
  149. </div>
  150. ) : isReachable ? (
  151. <div className="flex items-center gap-1 text-sm">
  152. <Wifi className="w-4 h-4 text-status-ok" />
  153. <span className={isOn ? 'text-status-ok' : 'text-bambu-gray'}>{status?.state || 'Unknown'}</span>
  154. </div>
  155. ) : (
  156. <div className="flex items-center gap-1 text-sm text-status-error">
  157. <WifiOff className="w-4 h-4" />
  158. <span>Offline</span>
  159. </div>
  160. )}
  161. {/* Admin page link - only for Tasmota */}
  162. {adminUrl && (
  163. <a
  164. href={adminUrl}
  165. target="_blank"
  166. rel="noopener noreferrer"
  167. className="flex items-center gap-1 px-2 py-0.5 bg-bambu-dark hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white text-xs rounded-full transition-colors"
  168. title="Open plug admin page"
  169. >
  170. <ExternalLink className="w-3 h-3" />
  171. Admin
  172. </a>
  173. )}
  174. </div>
  175. </div>
  176. {/* Linked Printer */}
  177. {linkedPrinter && (
  178. <div className="mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg">
  179. <span className="text-xs text-bambu-gray">Linked to: </span>
  180. <span className="text-sm text-white">{linkedPrinter.name}</span>
  181. </div>
  182. )}
  183. {/* Feature Badges */}
  184. {(plug.power_alert_enabled || plug.schedule_enabled || plug.plug_type === 'mqtt') && (
  185. <div className="flex flex-wrap gap-1.5 mb-3">
  186. {plug.plug_type === 'mqtt' && (
  187. <span className="flex items-center gap-1 px-2 py-0.5 bg-teal-500/20 text-teal-400 text-xs rounded-full">
  188. <Eye className="w-3 h-3" />
  189. Monitor Only
  190. </span>
  191. )}
  192. {plug.power_alert_enabled && (
  193. <span className="flex items-center gap-1 px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded-full">
  194. <Bell className="w-3 h-3" />
  195. Alerts
  196. </span>
  197. )}
  198. {plug.schedule_enabled && (
  199. <span className="flex items-center gap-1 px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded-full">
  200. <Calendar className="w-3 h-3" />
  201. {plug.schedule_on_time && plug.schedule_off_time
  202. ? `${plug.schedule_on_time} - ${plug.schedule_off_time}`
  203. : plug.schedule_on_time
  204. ? `On ${plug.schedule_on_time}`
  205. : `Off ${plug.schedule_off_time}`}
  206. </span>
  207. )}
  208. </div>
  209. )}
  210. {/* Quick Controls - hidden for MQTT plugs (monitor-only) */}
  211. {plug.plug_type !== 'mqtt' && (
  212. <div className="flex gap-2 mb-3">
  213. <Button
  214. size="sm"
  215. variant={isOn ? 'primary' : 'secondary'}
  216. disabled={!isReachable || isPending}
  217. onClick={() => setShowPowerOnConfirm(true)}
  218. className="flex-1"
  219. >
  220. {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
  221. On
  222. </Button>
  223. <Button
  224. size="sm"
  225. variant={!isOn ? 'primary' : 'secondary'}
  226. disabled={!isReachable || isPending}
  227. onClick={() => setShowPowerOffConfirm(true)}
  228. className="flex-1"
  229. >
  230. {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
  231. Off
  232. </Button>
  233. </div>
  234. )}
  235. {/* Energy display for MQTT plugs */}
  236. {plug.plug_type === 'mqtt' && status?.energy && (
  237. <div className="flex gap-2 mb-3 px-3 py-2 bg-bambu-dark rounded-lg">
  238. {status.energy.power !== null && status.energy.power !== undefined && (
  239. <div className="flex-1 text-center">
  240. <p className="text-lg font-semibold text-white">{Math.round(status.energy.power)}W</p>
  241. <p className="text-xs text-bambu-gray">Power</p>
  242. </div>
  243. )}
  244. {status.energy.today !== null && status.energy.today !== undefined && (
  245. <div className="flex-1 text-center border-l border-bambu-dark-tertiary">
  246. <p className="text-lg font-semibold text-white">{status.energy.today.toFixed(2)}</p>
  247. <p className="text-xs text-bambu-gray">kWh Today</p>
  248. </div>
  249. )}
  250. </div>
  251. )}
  252. {/* Toggle Settings Panel */}
  253. <button
  254. onClick={() => setIsExpanded(!isExpanded)}
  255. className="w-full flex items-center justify-between py-2 text-sm text-bambu-gray hover:text-white transition-colors"
  256. >
  257. <span className="flex items-center gap-2">
  258. <Settings2 className="w-4 h-4" />
  259. {plug.plug_type === 'mqtt' ? 'Settings' : 'Automation Settings'}
  260. </span>
  261. <span>{isExpanded ? '-' : '+'}</span>
  262. </button>
  263. {/* Expanded Settings */}
  264. {isExpanded && (
  265. <div className="pt-3 border-t border-bambu-dark-tertiary space-y-4">
  266. {/* Show in Switchbar Toggle */}
  267. <div className="flex items-center justify-between">
  268. <div className="flex items-center gap-2">
  269. <LayoutGrid className="w-4 h-4 text-bambu-green" />
  270. <div>
  271. <p className="text-sm text-white">Show in Switchbar</p>
  272. <p className="text-xs text-bambu-gray">Quick access from sidebar</p>
  273. </div>
  274. </div>
  275. <label className="relative inline-flex items-center cursor-pointer">
  276. <input
  277. type="checkbox"
  278. checked={plug.show_in_switchbar}
  279. onChange={(e) => updateMutation.mutate({ show_in_switchbar: e.target.checked })}
  280. className="sr-only peer"
  281. />
  282. <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
  283. </label>
  284. </div>
  285. {/* Automation controls - only for controllable plugs (not MQTT) */}
  286. {plug.plug_type !== 'mqtt' && (
  287. <>
  288. {/* Enabled Toggle */}
  289. <div className="flex items-center justify-between">
  290. <div>
  291. <p className="text-sm text-white">Enabled</p>
  292. <p className="text-xs text-bambu-gray">Enable automation for this plug</p>
  293. </div>
  294. <label className="relative inline-flex items-center cursor-pointer">
  295. <input
  296. type="checkbox"
  297. checked={plug.enabled}
  298. onChange={(e) => updateMutation.mutate({ enabled: e.target.checked })}
  299. className="sr-only peer"
  300. />
  301. <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
  302. </label>
  303. </div>
  304. {/* Auto On */}
  305. <div className="flex items-center justify-between">
  306. <div>
  307. <p className="text-sm text-white">Auto On</p>
  308. <p className="text-xs text-bambu-gray">Turn on when print starts</p>
  309. </div>
  310. <label className="relative inline-flex items-center cursor-pointer">
  311. <input
  312. type="checkbox"
  313. checked={plug.auto_on}
  314. onChange={(e) => updateMutation.mutate({ auto_on: e.target.checked })}
  315. className="sr-only peer"
  316. />
  317. <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
  318. </label>
  319. </div>
  320. {/* Auto Off */}
  321. <div className="flex items-center justify-between">
  322. <div>
  323. <p className="text-sm text-white">Auto Off</p>
  324. <p className="text-xs text-bambu-gray">Turn off when print completes (one-shot)</p>
  325. </div>
  326. <label className="relative inline-flex items-center cursor-pointer">
  327. <input
  328. type="checkbox"
  329. checked={plug.auto_off}
  330. onChange={(e) => updateMutation.mutate({ auto_off: e.target.checked })}
  331. className="sr-only peer"
  332. />
  333. <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
  334. </label>
  335. </div>
  336. {/* Delay Mode */}
  337. {plug.auto_off && (
  338. <div className="space-y-3 pl-4 border-l-2 border-bambu-dark-tertiary">
  339. <div>
  340. <p className="text-sm text-white mb-2">Turn Off Delay Mode</p>
  341. <div className="flex gap-2">
  342. <button
  343. onClick={() => updateMutation.mutate({ off_delay_mode: 'time' })}
  344. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
  345. plug.off_delay_mode === 'time'
  346. ? 'bg-bambu-green text-white'
  347. : 'bg-bambu-dark text-bambu-gray hover:text-white'
  348. }`}
  349. >
  350. <Clock className="w-4 h-4" />
  351. Time
  352. </button>
  353. <button
  354. onClick={() => updateMutation.mutate({ off_delay_mode: 'temperature' })}
  355. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
  356. plug.off_delay_mode === 'temperature'
  357. ? 'bg-bambu-green text-white'
  358. : 'bg-bambu-dark text-bambu-gray hover:text-white'
  359. }`}
  360. >
  361. <Thermometer className="w-4 h-4" />
  362. Temp
  363. </button>
  364. </div>
  365. </div>
  366. {plug.off_delay_mode === 'time' ? (
  367. <div>
  368. <label className="block text-xs text-bambu-gray mb-1">Delay (minutes)</label>
  369. <input
  370. type="number"
  371. min="1"
  372. max="60"
  373. value={plug.off_delay_minutes}
  374. onChange={(e) => updateMutation.mutate({ off_delay_minutes: parseInt(e.target.value) || 5 })}
  375. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
  376. />
  377. </div>
  378. ) : (
  379. <div>
  380. <label className="block text-xs text-bambu-gray mb-1">Temperature threshold (C)</label>
  381. <input
  382. type="number"
  383. min="30"
  384. max="100"
  385. value={plug.off_temp_threshold}
  386. onChange={(e) => updateMutation.mutate({ off_temp_threshold: parseInt(e.target.value) || 70 })}
  387. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
  388. />
  389. <p className="text-xs text-bambu-gray mt-1">Turns off when nozzle cools below this temperature</p>
  390. </div>
  391. )}
  392. </div>
  393. )}
  394. </>
  395. )}
  396. {/* Action Buttons */}
  397. <div className="flex gap-2 pt-2">
  398. <Button
  399. size="sm"
  400. variant="secondary"
  401. onClick={() => onEdit(plug)}
  402. className="flex-1"
  403. >
  404. <Edit2 className="w-4 h-4" />
  405. Edit
  406. </Button>
  407. <Button
  408. size="sm"
  409. variant="secondary"
  410. onClick={() => setShowDeleteConfirm(true)}
  411. className="text-red-400 hover:text-red-300"
  412. >
  413. <Trash2 className="w-4 h-4" />
  414. </Button>
  415. </div>
  416. </div>
  417. )}
  418. </CardContent>
  419. </Card>
  420. {/* Delete Confirmation */}
  421. {showDeleteConfirm && (
  422. <ConfirmModal
  423. title="Delete Smart Plug"
  424. message={`Are you sure you want to delete "${plug.name}"? This cannot be undone.`}
  425. confirmText="Delete"
  426. variant="danger"
  427. onConfirm={() => {
  428. deleteMutation.mutate();
  429. setShowDeleteConfirm(false);
  430. }}
  431. onCancel={() => setShowDeleteConfirm(false)}
  432. />
  433. )}
  434. {/* Power On Confirmation */}
  435. {showPowerOnConfirm && (
  436. <ConfirmModal
  437. title="Turn On Smart Plug"
  438. message={`Are you sure you want to turn on "${plug.name}"?`}
  439. confirmText="Turn On"
  440. variant="default"
  441. onConfirm={() => {
  442. controlMutation.mutate('on');
  443. setShowPowerOnConfirm(false);
  444. }}
  445. onCancel={() => setShowPowerOnConfirm(false)}
  446. />
  447. )}
  448. {/* Power Off Confirmation */}
  449. {showPowerOffConfirm && (
  450. <ConfirmModal
  451. title="Turn Off Smart Plug"
  452. message={`Are you sure you want to turn off "${plug.name}"? This will cut power to the connected device.`}
  453. confirmText="Turn Off"
  454. variant="danger"
  455. onConfirm={() => {
  456. controlMutation.mutate('off');
  457. setShowPowerOffConfirm(false);
  458. }}
  459. onCancel={() => setShowPowerOffConfirm(false)}
  460. />
  461. )}
  462. </>
  463. );
  464. }