HASensorModal.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. import { useEffect, useMemo, useState } from 'react';
  2. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { Gauge, Loader2, Save, Search, X } from 'lucide-react';
  4. import { useTranslation } from 'react-i18next';
  5. import { api } from '../api/client';
  6. import type { HADisplayEntity, Printer, PrinterHASensor } from '../api/client';
  7. import { Button } from './Button';
  8. import { useToast } from '../contexts/ToastContext';
  9. import { HA_SENSOR_BINARY_LABELS } from '../utils/haSensorDisplay';
  10. /**
  11. * Bind a Home Assistant entity to a printer, or edit an existing binding
  12. * (#1148, #448).
  13. *
  14. * The entity picker is the load-bearing part: kind, device_class and unit all
  15. * come from the entity rather than from the user, because getting any of them
  16. * wrong is a validation error from the backend that nobody could act on.
  17. */
  18. interface Props {
  19. sensor?: PrinterHASensor | null;
  20. printers: Printer[];
  21. onClose: () => void;
  22. }
  23. export function HASensorModal({ sensor, printers, onClose }: Props) {
  24. const { t } = useTranslation();
  25. const queryClient = useQueryClient();
  26. const { showToast } = useToast();
  27. const isEditing = !!sensor;
  28. const [printerId, setPrinterId] = useState<number | ''>(sensor?.printer_id ?? printers[0]?.id ?? '');
  29. const [entityId, setEntityId] = useState(sensor?.entity_id ?? '');
  30. const [kind, setKind] = useState<'binary' | 'numeric'>(sensor?.kind ?? 'binary');
  31. const [deviceClass, setDeviceClass] = useState<string | null>(sensor?.device_class ?? null);
  32. const [unit, setUnit] = useState<string | null>(sensor?.unit ?? null);
  33. const [name, setName] = useState(sensor?.name ?? '');
  34. const [alertState, setAlertState] = useState<'on' | 'off' | ''>(sensor?.alert_state ?? '');
  35. const [alertAbove, setAlertAbove] = useState(sensor?.alert_above?.toString() ?? '');
  36. const [alertBelow, setAlertBelow] = useState(sensor?.alert_below?.toString() ?? '');
  37. const [showOnCard, setShowOnCard] = useState(sensor?.show_on_printer_card ?? true);
  38. const [notifyOnAlert, setNotifyOnAlert] = useState(sensor?.notify_on_alert ?? false);
  39. const [blockPrint, setBlockPrint] = useState(sensor?.block_print ?? false);
  40. const [search, setSearch] = useState('');
  41. const [error, setError] = useState<string | null>(null);
  42. useEffect(() => {
  43. const onKey = (e: KeyboardEvent) => {
  44. if (e.key === 'Escape') onClose();
  45. };
  46. window.addEventListener('keydown', onKey);
  47. return () => window.removeEventListener('keydown', onKey);
  48. }, [onClose]);
  49. // Same gate and the same wording as AddSmartPlugModal: without a configured
  50. // Home Assistant the picker can only return an error, so say why up front
  51. // instead of showing an empty list.
  52. const { data: settings } = useQuery({
  53. queryKey: ['settings'],
  54. queryFn: api.getSettings,
  55. });
  56. const haConfigured = !!(settings?.ha_enabled && settings?.ha_url && settings?.ha_token);
  57. const { data: entities, isLoading: entitiesLoading, error: entitiesError } = useQuery({
  58. queryKey: ['bindableHAEntities'],
  59. queryFn: () => api.getBindableHAEntities(),
  60. enabled: haConfigured,
  61. });
  62. const filtered = useMemo(() => {
  63. const needle = search.trim().toLowerCase();
  64. const all = entities ?? [];
  65. if (!needle) return all;
  66. return all.filter(
  67. (e) => e.entity_id.toLowerCase().includes(needle) || e.friendly_name.toLowerCase().includes(needle)
  68. );
  69. }, [entities, search]);
  70. const selectEntity = (entity: HADisplayEntity) => {
  71. setEntityId(entity.entity_id);
  72. setDeviceClass(entity.device_class);
  73. setUnit(entity.unit_of_measurement);
  74. const nextKind = entity.domain === 'binary_sensor' ? 'binary' : 'numeric';
  75. setKind(nextKind);
  76. // Switching kind strands the other kind's alert fields, and the backend
  77. // rejects a numeric sensor that still carries an alert_state.
  78. if (nextKind === 'numeric') setAlertState('');
  79. else {
  80. setAlertAbove('');
  81. setAlertBelow('');
  82. }
  83. // Sliced to the column width: Home Assistant friendly names have no length
  84. // limit, and a long one would come back as a Pydantic error on a field the
  85. // user did not type into.
  86. if (!name.trim()) setName(entity.friendly_name.slice(0, 100));
  87. };
  88. const invalidate = () => {
  89. queryClient.invalidateQueries({ queryKey: ['haSensors'] });
  90. queryClient.invalidateQueries({ queryKey: ['haSensorReadings'] });
  91. };
  92. const saveMutation = useMutation({
  93. mutationFn: () => {
  94. const payload = {
  95. name: name.trim(),
  96. entity_id: entityId,
  97. kind,
  98. device_class: deviceClass,
  99. unit,
  100. alert_state: kind === 'binary' && alertState ? alertState : null,
  101. alert_above: kind === 'numeric' && alertAbove !== '' ? Number(alertAbove) : null,
  102. alert_below: kind === 'numeric' && alertBelow !== '' ? Number(alertBelow) : null,
  103. block_print: blockPrint,
  104. notify_on_alert: notifyOnAlert,
  105. show_on_printer_card: showOnCard,
  106. };
  107. return isEditing
  108. ? api.updateHASensor(sensor.id, payload)
  109. : api.createHASensor({ ...payload, printer_id: Number(printerId) });
  110. },
  111. onSuccess: () => {
  112. invalidate();
  113. showToast(isEditing ? t('haSensors.toast.updated') : t('haSensors.toast.created'), 'success');
  114. onClose();
  115. },
  116. onError: (err: Error) => setError(err.message),
  117. });
  118. const deleteMutation = useMutation({
  119. mutationFn: () => api.deleteHASensor(sensor!.id),
  120. onSuccess: () => {
  121. invalidate();
  122. showToast(t('haSensors.toast.deleted'), 'success');
  123. onClose();
  124. },
  125. onError: (err: Error) => setError(err.message),
  126. });
  127. const hasAlertCondition =
  128. kind === 'binary' ? alertState !== '' : alertAbove !== '' || alertBelow !== '';
  129. const handleSubmit = (e: React.FormEvent) => {
  130. e.preventDefault();
  131. setError(null);
  132. if (!entityId) return setError(t('haSensors.error.pickEntity'));
  133. if (!name.trim()) return setError(t('haSensors.error.nameRequired'));
  134. if (printerId === '') return setError(t('haSensors.error.printerRequired'));
  135. // Mirrors the backend rule, so the user is told before the round trip
  136. // rather than by a 422.
  137. if ((blockPrint || notifyOnAlert) && !hasAlertCondition) {
  138. return setError(t('haSensors.error.alertRequired'));
  139. }
  140. saveMutation.mutate();
  141. };
  142. const alertLabels = HA_SENSOR_BINARY_LABELS[deviceClass ?? ''];
  143. const stateLabel = (which: 'on' | 'off') => {
  144. const key = alertLabels?.[which] ?? which;
  145. return t(`haSensors.states.${key}`, { defaultValue: key });
  146. };
  147. const isPending = saveMutation.isPending || deleteMutation.isPending;
  148. return (
  149. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4" onClick={onClose}>
  150. <div
  151. className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto"
  152. onClick={(e) => e.stopPropagation()}
  153. >
  154. <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
  155. <div className="flex items-center gap-3">
  156. <div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
  157. <Gauge className="w-5 h-5" />
  158. </div>
  159. <h2 className="text-lg font-semibold text-white">
  160. {isEditing ? t('haSensors.editTitle') : t('haSensors.addTitle')}
  161. </h2>
  162. </div>
  163. <button onClick={onClose} className="text-bambu-gray hover:text-white transition-colors">
  164. <X className="w-5 h-5" />
  165. </button>
  166. </div>
  167. <form onSubmit={handleSubmit} className="p-6 space-y-4">
  168. {error && (
  169. <div className="p-3 bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/50 rounded-lg text-sm text-red-700 dark:text-red-400">
  170. {error}
  171. </div>
  172. )}
  173. {!isEditing && (
  174. <div>
  175. <label className="block text-sm text-bambu-gray mb-1">{t('haSensors.printer')}</label>
  176. <select
  177. value={printerId}
  178. onChange={(e) => setPrinterId(e.target.value === '' ? '' : Number(e.target.value))}
  179. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
  180. >
  181. {printers.map((p) => (
  182. <option key={p.id} value={p.id}>
  183. {p.name}
  184. </option>
  185. ))}
  186. </select>
  187. </div>
  188. )}
  189. {!haConfigured && (
  190. <div className="p-3 bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-400 dark:border-yellow-500/50 rounded-lg text-sm text-yellow-700 dark:text-yellow-400">
  191. {t('smartPlugs.haNotConfigured')}{' '}
  192. <span className="font-medium">{t('smartPlugs.haSettingsPath')}</span>
  193. </div>
  194. )}
  195. <div>
  196. <label className={`block text-sm text-bambu-gray mb-1 ${haConfigured ? '' : 'opacity-50'}`}>
  197. {t('haSensors.entity')}
  198. </label>
  199. {entitiesError && (
  200. <div className="p-3 mb-2 bg-red-100 dark:bg-red-500/20 rounded-lg text-sm text-red-700 dark:text-red-400">
  201. {(entitiesError as Error).message}
  202. </div>
  203. )}
  204. <div className="relative mb-2">
  205. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
  206. <input
  207. type="text"
  208. value={search}
  209. onChange={(e) => setSearch(e.target.value)}
  210. placeholder={t('haSensors.searchPlaceholder')}
  211. disabled={!haConfigured}
  212. className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed"
  213. />
  214. </div>
  215. <div
  216. className={`max-h-44 overflow-y-auto rounded-lg border border-bambu-dark-tertiary ${
  217. haConfigured ? '' : 'opacity-50'
  218. }`}
  219. >
  220. {!haConfigured && (
  221. <div className="p-3 text-sm text-bambu-gray">{t('haSensors.noEntities')}</div>
  222. )}
  223. {haConfigured && entitiesLoading && (
  224. <div className="flex items-center gap-2 p-3 text-sm text-bambu-gray">
  225. <Loader2 className="w-4 h-4 animate-spin" />
  226. {t('common.loading')}
  227. </div>
  228. )}
  229. {haConfigured && !entitiesLoading && filtered.length === 0 && (
  230. <div className="p-3 text-sm text-bambu-gray">{t('haSensors.noEntities')}</div>
  231. )}
  232. {haConfigured &&
  233. !entitiesLoading &&
  234. filtered.map((entity) => (
  235. <button
  236. key={entity.entity_id}
  237. type="button"
  238. onClick={() => selectEntity(entity)}
  239. className={`w-full text-left px-3 py-2 text-sm transition-colors ${
  240. entity.entity_id === entityId
  241. ? 'bg-bambu-green/20 text-bambu-green'
  242. : 'text-white hover:bg-bambu-dark'
  243. }`}
  244. >
  245. <div className="font-medium">{entity.friendly_name}</div>
  246. <div className="text-xs text-bambu-gray">
  247. {entity.entity_id}
  248. {entity.state !== null && ` — ${entity.state}`}
  249. {entity.unit_of_measurement ? ` ${entity.unit_of_measurement}` : ''}
  250. </div>
  251. </button>
  252. ))}
  253. </div>
  254. </div>
  255. <div>
  256. <label className="block text-sm text-bambu-gray mb-1">{t('haSensors.name')}</label>
  257. <input
  258. type="text"
  259. value={name}
  260. onChange={(e) => setName(e.target.value)}
  261. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
  262. />
  263. </div>
  264. <div>
  265. <label className="block text-sm text-bambu-gray mb-1">{t('haSensors.alertWhen')}</label>
  266. {kind === 'binary' ? (
  267. <select
  268. value={alertState}
  269. onChange={(e) => setAlertState(e.target.value as 'on' | 'off' | '')}
  270. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
  271. >
  272. <option value="">{t('haSensors.alertNever')}</option>
  273. <option value="on">{stateLabel('on')}</option>
  274. <option value="off">{stateLabel('off')}</option>
  275. </select>
  276. ) : (
  277. <div className="grid grid-cols-2 gap-3">
  278. <div>
  279. <span className="block text-xs text-bambu-gray mb-1">
  280. {t('haSensors.alertAbove')} {unit ?? ''}
  281. </span>
  282. <input
  283. type="number"
  284. step="any"
  285. value={alertAbove}
  286. onChange={(e) => setAlertAbove(e.target.value)}
  287. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
  288. />
  289. </div>
  290. <div>
  291. <span className="block text-xs text-bambu-gray mb-1">
  292. {t('haSensors.alertBelow')} {unit ?? ''}
  293. </span>
  294. <input
  295. type="number"
  296. step="any"
  297. value={alertBelow}
  298. onChange={(e) => setAlertBelow(e.target.value)}
  299. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
  300. />
  301. </div>
  302. </div>
  303. )}
  304. <p className="mt-1 text-xs text-bambu-gray">{t('haSensors.alertHint')}</p>
  305. </div>
  306. <label className="flex items-center gap-3 cursor-pointer">
  307. <input
  308. type="checkbox"
  309. checked={showOnCard}
  310. onChange={(e) => setShowOnCard(e.target.checked)}
  311. className="w-4 h-4"
  312. />
  313. <span className="text-sm text-white">{t('haSensors.showOnCard')}</span>
  314. </label>
  315. <label className="flex items-center gap-3 cursor-pointer">
  316. <input
  317. type="checkbox"
  318. checked={notifyOnAlert}
  319. onChange={(e) => setNotifyOnAlert(e.target.checked)}
  320. disabled={!hasAlertCondition}
  321. className="w-4 h-4"
  322. />
  323. <span className={`text-sm ${hasAlertCondition ? 'text-white' : 'text-bambu-gray'}`}>
  324. {t('haSensors.notifyOnAlert')}
  325. </span>
  326. </label>
  327. <label className="flex items-start gap-3 cursor-pointer">
  328. <input
  329. type="checkbox"
  330. checked={blockPrint}
  331. onChange={(e) => setBlockPrint(e.target.checked)}
  332. disabled={!hasAlertCondition}
  333. className="w-4 h-4 mt-0.5"
  334. />
  335. <span>
  336. <span className={`block text-sm ${hasAlertCondition ? 'text-white' : 'text-bambu-gray'}`}>
  337. {t('haSensors.blockPrint')}
  338. </span>
  339. <span className="block text-xs text-bambu-gray">{t('haSensors.blockPrintHint')}</span>
  340. </span>
  341. </label>
  342. <div className="flex items-center justify-between pt-2">
  343. {isEditing ? (
  344. <Button type="button" variant="danger" onClick={() => deleteMutation.mutate()} disabled={isPending}>
  345. {t('common.delete')}
  346. </Button>
  347. ) : (
  348. <span />
  349. )}
  350. <div className="flex items-center gap-2">
  351. <Button type="button" variant="secondary" onClick={onClose} disabled={isPending}>
  352. {t('common.cancel')}
  353. </Button>
  354. {/* An unconfigured Home Assistant leaves nothing to bind to.
  355. Editing an existing sensor still saves — its alert rule and
  356. card visibility are Bambuddy's own settings and do not need
  357. Home Assistant to be reachable to change. */}
  358. <Button type="submit" disabled={isPending || (!haConfigured && !isEditing)}>
  359. {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
  360. {t('common.save')}
  361. </Button>
  362. </div>
  363. </div>
  364. </form>
  365. </div>
  366. </div>
  367. );
  368. }