import { useEffect, useMemo, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Gauge, Loader2, Plus, Save, Search, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { api } from '../api/client'; import type { HADisplayEntity, LocationHASensor, StorageLocation } from '../api/client'; import { Button } from './Button'; import { ConfirmModal } from './ConfirmModal'; import { LocationsModal } from './LocationsModal'; import { useToast } from '../contexts/ToastContext'; import { loadLocationSensorDefaults } from '../utils/locationSensorDefaults'; import { HA_SENSOR_BINARY_LABELS } from '../utils/haSensorDisplay'; interface Props { sensor?: LocationHASensor | null; locations: StorageLocation[]; onClose: () => void; } type SensorCategory = 'temperature' | 'humidity' | 'battery'; // Doubles as the picker's filter (see `entities` below): an entity with no // category cannot be bound to a location at all. // // "moisture" is deliberately absent. It is Home Assistant's binary wet/dry // class, not a humidity percentage, and mapping it here made it a humidity // sensor everywhere downstream — it landed in the percent-formatted humidity // column rendering "wet", blocked a real humidity sensor on the same location // via the one-per-category rule, and could never take the seeded thresholds // because the schema rejects alert_above/alert_below for kind="binary". // A storage location wants a hygrometer, not a leak detector. function categoryFor(deviceClass: string | null): SensorCategory | null { if (deviceClass === 'temperature') return 'temperature'; if (deviceClass === 'humidity') return 'humidity'; if (deviceClass === 'battery') return 'battery'; return null; } const CATEGORY_SUFFIXES: Record = { temperature: 'temperature', humidity: 'humidity', battery: 'battery', }; function findSiblingEntities( entityId: string, category: SensorCategory, candidates: HADisplayEntity[] ): HADisplayEntity[] { const suffix = CATEGORY_SUFFIXES[category]; const lower = entityId.toLowerCase(); if (!lower.endsWith(suffix)) return []; const prefix = entityId.slice(0, entityId.length - suffix.length); const siblings: HADisplayEntity[] = []; (Object.keys(CATEGORY_SUFFIXES) as SensorCategory[]).forEach((otherCategory) => { if (otherCategory === category) return; const candidateId = `${prefix}${CATEGORY_SUFFIXES[otherCategory]}`.toLowerCase(); const match = candidates.find((c) => c.entity_id.toLowerCase() === candidateId); if (match) siblings.push(match); }); return siblings; } export function LocationHASensorModal({ sensor, locations, onClose }: Props) { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const isEditing = !!sensor; const [locationId, setLocationId] = useState(sensor?.location_id ?? locations[0]?.id ?? ''); const [entityId, setEntityId] = useState(sensor?.entity_id ?? ''); const [kind, setKind] = useState<'binary' | 'numeric'>(sensor?.kind ?? 'numeric'); const [deviceClass, setDeviceClass] = useState(sensor?.device_class ?? null); const [unit, setUnit] = useState(sensor?.unit ?? null); const [name, setName] = useState(sensor?.name ?? ''); // Tracks the last name we auto-filled (or the initial saved name), so // switching to a different entity can follow along with the new friendly // name — but only while the field still holds what we put there. A name // the user typed themselves is never overwritten by an entity change. const autoFilledNameRef = useRef(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 [notifyOnAlert, setNotifyOnAlert] = useState(sensor?.notify_on_alert ?? false); const [showOnCard, setShowOnCard] = useState(sensor?.show_on_card ?? true); const [search, setSearch] = useState(''); const [error, setError] = useState(null); const [showAddLocationModal, setShowAddLocationModal] = useState(false); const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false); const [overwriteTarget, setOverwriteTarget] = useState(null); const [showAutoAddConfirm, setShowAutoAddConfirm] = useState(false); const [autoAddCandidates, setAutoAddCandidates] = useState([]); const [autoAddSelected, setAutoAddSelected] = useState>({}); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, }); const haConfigured = !!(settings?.ha_enabled && settings?.ha_url && settings?.ha_token); const { data: rawEntities, isLoading: entitiesLoading, error: entitiesError } = useQuery({ queryKey: ['bindableLocationHAEntities'], queryFn: () => api.getBindableLocationHAEntities(), enabled: haConfigured, }); const entities = useMemo( () => (rawEntities ?? []).filter((e) => categoryFor(e.device_class) !== null), [rawEntities] ); const { data: allLocationSensors } = useQuery({ queryKey: ['locationHaSensors'], queryFn: () => api.getLocationHASensors(), }); const selected = entities.find((e) => e.entity_id === entityId); const filtered = useMemo(() => { const needle = search.trim().toLowerCase(); const all = entities ?? []; const matches = needle ? all.filter((e) => e.entity_id.toLowerCase().includes(needle) || e.friendly_name.toLowerCase().includes(needle)) : all; if (!selected || matches[0]?.entity_id === selected.entity_id) return matches; return [selected, ...matches.filter((e) => e.entity_id !== selected.entity_id)]; }, [entities, search, selected]); const selectEntity = (entity: HADisplayEntity) => { setEntityId(entity.entity_id); setDeviceClass(entity.device_class); // Sliced like the name below: the unit is snapshotted from Home Assistant, // not typed by the user, so an oversized one must not come back as a 422 // on a field the form never showed them. The column is String(16). setUnit(entity.unit_of_measurement?.slice(0, 16) ?? null); const nextKind = entity.domain === 'binary_sensor' ? 'binary' : 'numeric'; setKind(nextKind); 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. Follows the entity picker as long as the name // still matches what we last auto-filled — a name the user typed // themselves is left alone even when they pick a different entity. if (name === autoFilledNameRef.current) { const nextName = entity.friendly_name.slice(0, 100); setName(nextName); autoFilledNameRef.current = nextName; } if (!isEditing) { const category = categoryFor(entity.device_class); if (category) { const defaults = loadLocationSensorDefaults(settings?.location_sensor_alert_defaults)[category]; if (nextKind === 'numeric') { setAlertAbove(category === 'battery' ? '' : defaults.alertAbove); setAlertBelow(defaults.alertBelow); } setNotifyOnAlert(defaults.notifyOnAlert); setShowOnCard(defaults.showOnCard); } } }; const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['locationHaSensors'] }); queryClient.invalidateQueries({ queryKey: ['locationHaSensorReadings'] }); }; const buildPrimaryPayload = () => ({ name: name.trim(), entity_id: entityId, kind, device_class: deviceClass, unit, alert_state: kind === 'binary' && alertState ? alertState : null, alert_above: kind === 'numeric' && categoryFor(deviceClass) !== 'battery' && alertAbove !== '' ? Number(alertAbove) : null, alert_below: kind === 'numeric' && alertBelow !== '' ? Number(alertBelow) : null, notify_on_alert: notifyOnAlert, show_on_card: showOnCard, }); const saveMutation = useMutation({ mutationFn: () => { const payload = buildPrimaryPayload(); return isEditing ? api.updateLocationHASensor(sensor.id, payload) : api.createLocationHASensor({ ...payload, location_id: Number(locationId) }); }, onSuccess: () => { invalidate(); showToast(isEditing ? t('locationHaSensors.toast.updated') : t('locationHaSensors.toast.created'), 'success'); onClose(); }, onError: (err: Error) => setError(err.message), }); const autoAddMutation = useMutation({ mutationFn: async () => { const targetLocationId = Number(locationId); await api.createLocationHASensor({ ...buildPrimaryPayload(), location_id: targetLocationId }); const defaults = loadLocationSensorDefaults(settings?.location_sensor_alert_defaults); const chosen = autoAddCandidates.filter((entity) => autoAddSelected[entity.entity_id]); for (const entity of chosen) { const categoryDefaults = categoryFor(entity.device_class); const d = categoryDefaults ? defaults[categoryDefaults] : null; await api.createLocationHASensor({ name: entity.friendly_name.slice(0, 100), entity_id: entity.entity_id, kind: entity.domain === 'binary_sensor' ? 'binary' : 'numeric', device_class: entity.device_class, unit: entity.unit_of_measurement?.slice(0, 16) ?? null, alert_state: null, alert_above: categoryDefaults !== 'battery' && d && d.alertAbove !== '' ? Number(d.alertAbove) : null, alert_below: d && d.alertBelow !== '' ? Number(d.alertBelow) : null, notify_on_alert: d?.notifyOnAlert ?? false, show_on_card: d?.showOnCard ?? showOnCard, location_id: targetLocationId, }); } return chosen; }, onSuccess: (chosen) => { invalidate(); setShowAutoAddConfirm(false); if (chosen.length > 0) { showToast(t('locationHaSensors.autoAdd.added', { names: chosen.map((e) => e.entity_id).join(', ') }), 'success'); } else { showToast(t('locationHaSensors.toast.created'), 'success'); } onClose(); }, onError: (err: Error) => { invalidate(); setShowAutoAddConfirm(false); setError(err.message); }, }); const deleteMutation = useMutation({ mutationFn: () => api.deleteLocationHASensor(sensor!.id), onSuccess: () => { invalidate(); showToast(t('locationHaSensors.toast.deleted'), 'success'); onClose(); }, onError: (err: Error) => setError(err.message), }); const overwriteMutation = useMutation({ // PATCH the existing row onto the new entity instead of deleting it and // creating a replacement: a delete-then-create left a window where, if // the create failed, the old binding was already gone and nothing had // taken its place. A single PATCH either lands or leaves the original // binding untouched. mutationFn: () => api.updateLocationHASensor(overwriteTarget!.id, buildPrimaryPayload()), onSuccess: () => { invalidate(); setShowOverwriteConfirm(false); setOverwriteTarget(null); showToast(t('locationHaSensors.toast.updated'), 'success'); onClose(); }, onError: (err: Error) => { setShowOverwriteConfirm(false); setOverwriteTarget(null); 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 (locationId === '') return setError(t('locationHaSensors.error.locationRequired')); if (notifyOnAlert && !hasAlertCondition) { return setError(t('haSensors.error.alertRequired')); } if (!isEditing) { const locationSensors = (allLocationSensors ?? []).filter((s) => s.location_id === Number(locationId)); const category = categoryFor(deviceClass); if (category) { const conflicting = locationSensors.find((s) => categoryFor(s.device_class) === category); if (conflicting) { setOverwriteTarget(conflicting); setShowOverwriteConfirm(true); return; } } if (locationSensors.length === 0 && category) { const siblings = findSiblingEntities(entityId, category, entities); if (siblings.length > 0) { setAutoAddCandidates(siblings); setAutoAddSelected(Object.fromEntries(siblings.map((s) => [s.entity_id, true]))); setShowAutoAddConfirm(true); return; } showToast(t('locationHaSensors.autoAdd.noneFound'), 'info'); } } saveMutation.mutate(); }; const alertLabels = HA_SENSOR_BINARY_LABELS[deviceClass ?? '']; const stateLabel = (which: 'on' | 'off') => { const key = alertLabels?.[which] ?? which; return t(`haSensors.states.${key}`, { defaultValue: key }); }; const isPending = saveMutation.isPending || deleteMutation.isPending || overwriteMutation.isPending || autoAddMutation.isPending; const currentLocation = locations.find((l) => l.id === Number(locationId)); // Escape closes the modal, but not while a mutation is mid-flight — a // stray keypress landing between the overwrite PATCH's dispatch and its // response must not drop the user out with an orphaned request. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !isPending) onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose, isPending]); return ( <>
{ if (!isPending) onClose(); }} >
e.stopPropagation()} >

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

{error && (
{error}
)}
{isEditing ? (
{locations.find((l) => l.id === sensor.location_id)?.name ?? t('locationHaSensors.unknownLocation')}
) : (
)}
{!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" />
{entityId && !selected && (
{t('locationHaSensors.currentlyBound', { entity: entityId })}
)} {!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" />
{entityId && (
{kind === 'binary' ? ( ) : categoryFor(deviceClass) === 'battery' ? (
{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.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 ? ( ) : ( )}
{showOverwriteConfirm && overwriteTarget && ( overwriteMutation.mutate()} onCancel={() => { setShowOverwriteConfirm(false); setOverwriteTarget(null); }} /> )} {showAutoAddConfirm && autoAddCandidates.length > 0 && ( autoAddSelected[e.entity_id])} onConfirm={() => autoAddMutation.mutate()} // "Cancel" here still saves the sensor the user picked — it only // skips the siblings — so the button needs to say what it does // rather than implying the whole thing is being abandoned. cancelText={t('locationHaSensors.autoAdd.onlyThisOne')} onCancel={() => { setShowAutoAddConfirm(false); setAutoAddCandidates([]); setAutoAddSelected({}); saveMutation.mutate(); }} >
{autoAddCandidates.map((entity) => ( ))}
)} {showAddLocationModal && ( setShowAddLocationModal(false)} onPickLocation={(id) => { setLocationId(id); setShowAddLocationModal(false); }} startCreating /> )} ); }