import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Gauge, Loader2, Save, Search, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { api } from '../api/client'; import type { HADisplayEntity, Printer, PrinterHASensor } from '../api/client'; import { Button } from './Button'; import { useToast } from '../contexts/ToastContext'; /** * Bind a Home Assistant entity to a printer, or edit an existing binding * (#1148, #448). * * The entity picker is the load-bearing part: kind, device_class and unit all * come from the entity rather than from the user, because getting any of them * wrong is a validation error from the backend that nobody could act on. */ interface Props { sensor?: PrinterHASensor | null; printers: Printer[]; onClose: () => void; } // The alert wording follows the device class, so a door offers "Open" rather // than "On". Shared with PrinterHASensorRow's rendering of the same classes. const ALERT_LABEL_KEYS: Record = { door: { on: 'open', off: 'closed' }, garage_door: { on: 'open', off: 'closed' }, window: { on: 'open', off: 'closed' }, opening: { on: 'open', off: 'closed' }, lock: { on: 'unlocked', off: 'locked' }, motion: { on: 'detected', off: 'clear' }, occupancy: { on: 'detected', off: 'clear' }, presence: { on: 'detected', off: 'clear' }, smoke: { on: 'detected', off: 'clear' }, gas: { on: 'detected', off: 'clear' }, moisture: { on: 'wet', off: 'dry' }, problem: { on: 'problem', off: 'ok' }, safety: { on: 'problem', off: 'ok' }, running: { on: 'running', off: 'stopped' }, }; export function HASensorModal({ sensor, printers, onClose }: Props) { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const isEditing = !!sensor; const [printerId, setPrinterId] = useState(sensor?.printer_id ?? printers[0]?.id ?? ''); const [entityId, setEntityId] = useState(sensor?.entity_id ?? ''); const [kind, setKind] = useState<'binary' | 'numeric'>(sensor?.kind ?? 'binary'); const [deviceClass, setDeviceClass] = useState(sensor?.device_class ?? null); const [unit, setUnit] = useState(sensor?.unit ?? null); const [name, setName] = useState(sensor?.name ?? ''); const [alertState, setAlertState] = useState<'on' | 'off' | ''>(sensor?.alert_state ?? ''); const [alertAbove, setAlertAbove] = useState(sensor?.alert_above?.toString() ?? ''); const [alertBelow, setAlertBelow] = useState(sensor?.alert_below?.toString() ?? ''); const [showOnCard, setShowOnCard] = useState(sensor?.show_on_printer_card ?? true); const [notifyOnAlert, setNotifyOnAlert] = useState(sensor?.notify_on_alert ?? false); const [blockPrint, setBlockPrint] = useState(sensor?.block_print ?? false); const [search, setSearch] = useState(''); const [error, setError] = useState(null); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); // Same gate and the same wording as AddSmartPlugModal: without a configured // Home Assistant the picker can only return an error, so say why up front // instead of showing an empty list. const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, }); const haConfigured = !!(settings?.ha_enabled && settings?.ha_url && settings?.ha_token); const { data: entities, isLoading: entitiesLoading, error: entitiesError } = useQuery({ queryKey: ['bindableHAEntities'], queryFn: () => api.getBindableHAEntities(), enabled: haConfigured, }); const filtered = useMemo(() => { const needle = search.trim().toLowerCase(); const all = entities ?? []; if (!needle) return all; return all.filter( (e) => e.entity_id.toLowerCase().includes(needle) || e.friendly_name.toLowerCase().includes(needle) ); }, [entities, search]); const selectEntity = (entity: HADisplayEntity) => { setEntityId(entity.entity_id); setDeviceClass(entity.device_class); setUnit(entity.unit_of_measurement); const nextKind = entity.domain === 'binary_sensor' ? 'binary' : 'numeric'; setKind(nextKind); // Switching kind strands the other kind's alert fields, and the backend // rejects a numeric sensor that still carries an alert_state. if (nextKind === 'numeric') setAlertState(''); else { setAlertAbove(''); setAlertBelow(''); } // Sliced to the column width: Home Assistant friendly names have no length // limit, and a long one would come back as a Pydantic error on a field the // user did not type into. if (!name.trim()) setName(entity.friendly_name.slice(0, 100)); }; const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['haSensors'] }); queryClient.invalidateQueries({ queryKey: ['haSensorReadings'] }); }; const saveMutation = useMutation({ mutationFn: () => { const payload = { name: name.trim(), entity_id: entityId, kind, device_class: deviceClass, unit, alert_state: kind === 'binary' && alertState ? alertState : null, alert_above: kind === 'numeric' && alertAbove !== '' ? Number(alertAbove) : null, alert_below: kind === 'numeric' && alertBelow !== '' ? Number(alertBelow) : null, block_print: blockPrint, notify_on_alert: notifyOnAlert, show_on_printer_card: showOnCard, }; return isEditing ? api.updateHASensor(sensor.id, payload) : api.createHASensor({ ...payload, printer_id: Number(printerId) }); }, onSuccess: () => { invalidate(); showToast(isEditing ? t('haSensors.toast.updated') : t('haSensors.toast.created'), 'success'); onClose(); }, onError: (err: Error) => setError(err.message), }); const deleteMutation = useMutation({ mutationFn: () => api.deleteHASensor(sensor!.id), onSuccess: () => { invalidate(); showToast(t('haSensors.toast.deleted'), 'success'); onClose(); }, onError: (err: Error) => setError(err.message), }); const hasAlertCondition = kind === 'binary' ? alertState !== '' : alertAbove !== '' || alertBelow !== ''; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setError(null); if (!entityId) return setError(t('haSensors.error.pickEntity')); if (!name.trim()) return setError(t('haSensors.error.nameRequired')); if (printerId === '') return setError(t('haSensors.error.printerRequired')); // Mirrors the backend rule, so the user is told before the round trip // rather than by a 422. if ((blockPrint || notifyOnAlert) && !hasAlertCondition) { return setError(t('haSensors.error.alertRequired')); } saveMutation.mutate(); }; const alertLabels = ALERT_LABEL_KEYS[deviceClass ?? '']; const stateLabel = (which: 'on' | 'off') => { const key = alertLabels?.[which] ?? which; return t(`haSensors.states.${key}`, { defaultValue: key }); }; const isPending = saveMutation.isPending || deleteMutation.isPending; return (
e.stopPropagation()} >

{isEditing ? t('haSensors.editTitle') : t('haSensors.addTitle')}

{error && (
{error}
)} {!isEditing && (
)} {!haConfigured && (
{t('smartPlugs.haNotConfigured')}{' '} {t('smartPlugs.haSettingsPath')}
)}
{entitiesError && (
{(entitiesError as Error).message}
)}
setSearch(e.target.value)} placeholder={t('haSensors.searchPlaceholder')} disabled={!haConfigured} 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" />
{!haConfigured && (
{t('haSensors.noEntities')}
)} {haConfigured && entitiesLoading && (
{t('common.loading')}
)} {haConfigured && !entitiesLoading && filtered.length === 0 && (
{t('haSensors.noEntities')}
)} {haConfigured && !entitiesLoading && filtered.map((entity) => ( ))}
setName(e.target.value)} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white" />
{kind === 'binary' ? ( ) : (
{t('haSensors.alertAbove')} {unit ?? ''} setAlertAbove(e.target.value)} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white" />
{t('haSensors.alertBelow')} {unit ?? ''} setAlertBelow(e.target.value)} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white" />
)}

{t('haSensors.alertHint')}

{isEditing ? ( ) : ( )}
{/* An unconfigured Home Assistant leaves nothing to bind to. Editing an existing sensor still saves — its alert rule and card visibility are Bambuddy's own settings and do not need Home Assistant to be reachable to change. */}
); }