mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
Binds binary_sensor and reading-carrying sensor entities to a printer and renders their state on its card, worded by Home Assistant's device_class. Optional per-sensor alert condition drives a notification on the transition into the alert state and an opt-in interlock that holds queued prints while alerting -- a hold with a readable waiting_reason, never a failure, and only ever on a sensor that was read successfully. Sensors get their own table rather than a wider entity pattern on SmartPlug: get_smart_plug_by_printer would otherwise hand the card's power button a door contact to switch. The hold is passed to the model matcher directly rather than merged into busy_printers: _check_auto_drying reads that set as "is currently printing" and would put an idle-but-held printer down the mid-print drying path. The notification_providers migration spells its default FALSE, not 0 -- Postgres rejects an integer default for a boolean and _safe_execute swallows the error.
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
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<string, { on: string; off: string }> = {
|
||||
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<number | ''>(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<string | null>(sensor?.device_class ?? null);
|
||||
const [unit, setUnit] = useState<string | null>(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<string | null>(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 (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
||||
<div
|
||||
className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
|
||||
<Gauge className="w-5 h-5" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{isEditing ? t('haSensors.editTitle') : t('haSensors.addTitle')}
|
||||
</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-bambu-gray hover:text-white transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<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">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditing && (
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('haSensors.printer')}</label>
|
||||
<select
|
||||
value={printerId}
|
||||
onChange={(e) => setPrinterId(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
|
||||
>
|
||||
{printers.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!haConfigured && (
|
||||
<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">
|
||||
{t('smartPlugs.haNotConfigured')}{' '}
|
||||
<span className="font-medium">{t('smartPlugs.haSettingsPath')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={`block text-sm text-bambu-gray mb-1 ${haConfigured ? '' : 'opacity-50'}`}>
|
||||
{t('haSensors.entity')}
|
||||
</label>
|
||||
{entitiesError && (
|
||||
<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">
|
||||
{(entitiesError as Error).message}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative mb-2">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`max-h-44 overflow-y-auto rounded-lg border border-bambu-dark-tertiary ${
|
||||
haConfigured ? '' : 'opacity-50'
|
||||
}`}
|
||||
>
|
||||
{!haConfigured && (
|
||||
<div className="p-3 text-sm text-bambu-gray">{t('haSensors.noEntities')}</div>
|
||||
)}
|
||||
{haConfigured && entitiesLoading && (
|
||||
<div className="flex items-center gap-2 p-3 text-sm text-bambu-gray">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)}
|
||||
{haConfigured && !entitiesLoading && filtered.length === 0 && (
|
||||
<div className="p-3 text-sm text-bambu-gray">{t('haSensors.noEntities')}</div>
|
||||
)}
|
||||
{haConfigured &&
|
||||
!entitiesLoading &&
|
||||
filtered.map((entity) => (
|
||||
<button
|
||||
key={entity.entity_id}
|
||||
type="button"
|
||||
onClick={() => selectEntity(entity)}
|
||||
className={`w-full text-left px-3 py-2 text-sm transition-colors ${
|
||||
entity.entity_id === entityId
|
||||
? 'bg-bambu-green/20 text-bambu-green'
|
||||
: 'text-white hover:bg-bambu-dark'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{entity.friendly_name}</div>
|
||||
<div className="text-xs text-bambu-gray">
|
||||
{entity.entity_id}
|
||||
{entity.state !== null && ` — ${entity.state}`}
|
||||
{entity.unit_of_measurement ? ` ${entity.unit_of_measurement}` : ''}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('haSensors.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-bambu-gray mb-1">{t('haSensors.alertWhen')}</label>
|
||||
{kind === 'binary' ? (
|
||||
<select
|
||||
value={alertState}
|
||||
onChange={(e) => setAlertState(e.target.value as 'on' | 'off' | '')}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
|
||||
>
|
||||
<option value="">{t('haSensors.alertNever')}</option>
|
||||
<option value="on">{stateLabel('on')}</option>
|
||||
<option value="off">{stateLabel('off')}</option>
|
||||
</select>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<span className="block text-xs text-bambu-gray mb-1">
|
||||
{t('haSensors.alertAbove')} {unit ?? ''}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={alertAbove}
|
||||
onChange={(e) => setAlertAbove(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-xs text-bambu-gray mb-1">
|
||||
{t('haSensors.alertBelow')} {unit ?? ''}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={alertBelow}
|
||||
onChange={(e) => setAlertBelow(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-bambu-gray">{t('haSensors.alertHint')}</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showOnCard}
|
||||
onChange={(e) => setShowOnCard(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm text-white">{t('haSensors.showOnCard')}</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notifyOnAlert}
|
||||
onChange={(e) => setNotifyOnAlert(e.target.checked)}
|
||||
disabled={!hasAlertCondition}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className={`text-sm ${hasAlertCondition ? 'text-white' : 'text-bambu-gray'}`}>
|
||||
{t('haSensors.notifyOnAlert')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={blockPrint}
|
||||
onChange={(e) => setBlockPrint(e.target.checked)}
|
||||
disabled={!hasAlertCondition}
|
||||
className="w-4 h-4 mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<span className={`block text-sm ${hasAlertCondition ? 'text-white' : 'text-bambu-gray'}`}>
|
||||
{t('haSensors.blockPrint')}
|
||||
</span>
|
||||
<span className="block text-xs text-bambu-gray">{t('haSensors.blockPrintHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
{isEditing ? (
|
||||
<Button type="button" variant="danger" onClick={() => deleteMutation.mutate()} disabled={isPending}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose} disabled={isPending}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
{/* 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. */}
|
||||
<Button type="submit" disabled={isPending || (!haConfigured && !isEditing)}>
|
||||
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user