Show Home Assistant sensors on the printer card (#1148, #448)

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:
maziggy
2026-08-05 14:26:38 +02:00
parent 945d4ca6eb
commit cd004df817
45 changed files with 3782 additions and 172 deletions
@@ -45,6 +45,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
const [onStockBreakAlert, setOnStockBreakAlert] = useState(provider?.on_stock_break_alert ?? false);
const [onPlateClearRequired, setOnPlateClearRequired] = useState(provider?.on_plate_clear_required ?? false);
const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false);
const [onHaSensorAlert, setOnHaSensorAlert] = useState(provider?.on_ha_sensor_alert ?? false);
const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? false);
// Provider-specific config (scalar fields only — event_priorities is split out
@@ -200,6 +201,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
on_stock_break_alert: onStockBreakAlert,
on_plate_clear_required: onPlateClearRequired,
on_bed_cooled: onBedCooled,
on_ha_sensor_alert: onHaSensorAlert,
on_first_layer_complete: onFirstLayerComplete,
};
@@ -635,6 +637,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
<span className="text-sm text-white">{t('notifications.offline')}</span>
<Toggle checked={onPrinterOffline} onChange={setOnPrinterOffline} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">{t('notifications.haSensorAlert')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.haSensorAlertDescription')}</span>
</div>
<Toggle checked={onHaSensorAlert} onChange={setOnHaSensorAlert} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">{t('notifications.error')}</span>
<Toggle checked={onPrinterError} onChange={setOnPrinterError} />
@@ -688,6 +697,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });
if (onPrinterError) enabledEvents.push({ key: 'on_printer_error', label: t('notifications.error') });
if (onHaSensorAlert) enabledEvents.push({ key: 'on_ha_sensor_alert', label: t('notifications.haSensorAlert') });
if (onAiFailureDetection) enabledEvents.push({ key: 'on_ai_failure_detection', label: t('notifications.aiFailureDetection') });
if (onFilamentLow) enabledEvents.push({ key: 'on_filament_low', label: t('notifications.lowFilament') });
if (onMaintenanceDue) enabledEvents.push({ key: 'on_maintenance_due', label: t('notifications.maintenance') });
+413
View File
@@ -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>
);
}
@@ -144,6 +144,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{provider.on_ai_failure_detection && (
<span className="px-2 py-0.5 bg-fuchsia-100 dark:bg-fuchsia-500/20 text-fuchsia-700 dark:text-fuchsia-300 text-xs rounded">{t('notifications.aiFailureDetection')}</span>
)}
{provider.on_ha_sensor_alert && (
<span className="px-2 py-0.5 bg-indigo-100 dark:bg-indigo-500/20 text-indigo-700 dark:text-indigo-300 text-xs rounded">{t('notifications.haSensorAlert')}</span>
)}
{provider.on_filament_low && (
<span className="px-2 py-0.5 bg-cyan-100 dark:bg-cyan-500/20 text-cyan-700 dark:text-cyan-400 text-xs rounded">{t('notifications.lowFilament')}</span>
)}
@@ -394,6 +397,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">{t('notifications.haSensorAlert')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.haSensorAlertDescription')}</p>
</div>
<Toggle
checked={provider.on_ha_sensor_alert ?? false}
onChange={(checked) => updateMutation.mutate({ on_ha_sensor_alert: checked })}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">{t('notifications.lowFilamentLabel')}</p>
<Toggle
@@ -0,0 +1,142 @@
import { useQuery } from '@tanstack/react-query';
import {
Activity,
AlertTriangle,
DoorClosed,
DoorOpen,
Droplets,
Gauge,
Lock,
LockOpen,
Thermometer,
Wind,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import type { PrinterHASensorReading } from '../api/client';
/**
* The Home Assistant sensors bound to a printer, on its card (#1148, #448).
*
* Read-only by design — these are contacts and thermometers, not switches, so
* nothing here is clickable. The sibling "HA:" row above handles the entities
* you can actually operate.
*/
// Home Assistant's own device_class decides the wording, so a door reads
// "Open"/"Closed" rather than the "on"/"off" the API actually carries. Classes
// absent from this map fall through to on/off, which is what HA itself shows
// for a binary_sensor with no class.
const BINARY_LABELS: 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' },
};
const ICONS: Record<string, LucideIcon> = {
door: DoorOpen,
garage_door: DoorOpen,
window: DoorOpen,
opening: DoorOpen,
lock: LockOpen,
temperature: Thermometer,
humidity: Droplets,
moisture: Droplets,
motion: Activity,
occupancy: Activity,
presence: Activity,
smoke: AlertTriangle,
gas: AlertTriangle,
problem: AlertTriangle,
safety: AlertTriangle,
running: Wind,
};
function iconFor(reading: PrinterHASensorReading): LucideIcon {
const deviceClass = reading.device_class ?? '';
// A closed door wants the closed-door glyph — the map is keyed by class, so
// the two states that have a distinct "off" icon are special-cased here.
if (reading.state === 'off') {
if (ICONS[deviceClass] === DoorOpen) return DoorClosed;
if (ICONS[deviceClass] === LockOpen) return Lock;
}
return ICONS[deviceClass] ?? (reading.kind === 'numeric' ? Gauge : Activity);
}
interface Props {
printerId: number;
}
export function PrinterHASensorRow({ printerId }: Props) {
const { t } = useTranslation();
const { data: readings } = useQuery({
queryKey: ['haSensorReadings', printerId],
queryFn: () => api.getHASensorReadings(printerId),
// Served from the backend poller's cache, so this costs a local request
// and never a Home Assistant round trip. Matched to the poller's own
// cadence — refetching faster would only re-read the same reading.
refetchInterval: 15000,
});
if (!readings?.length) return null;
const describe = (reading: PrinterHASensorReading): string => {
if (!reading.reachable || reading.state === null) return t('haSensors.unavailable');
if (reading.kind === 'numeric') {
if (reading.value === null) return reading.state;
return reading.unit ? `${reading.value} ${reading.unit}` : String(reading.value);
}
const labels = BINARY_LABELS[reading.device_class ?? ''];
const key = labels ? labels[reading.state === 'on' ? 'on' : 'off'] : reading.state;
return t(`haSensors.states.${key}`, { defaultValue: key });
};
return (
<div className="flex items-center gap-2 mt-2">
<Gauge className="w-[var(--pc-i35,0.875rem)] h-[var(--pc-i35,0.875rem)] text-blue-600 dark:text-blue-400 flex-shrink-0" />
<span className="text-xs text-bambu-gray">{t('haSensors.label')}</span>
<div className="h-[2px] w-5 bg-bambu-dark-tertiary/50" />
<div className="flex flex-wrap gap-1">
{readings.map((reading) => {
const Icon = iconFor(reading);
const unreachable = !reading.reachable || reading.state === null;
return (
<span
key={reading.id}
title={
reading.block_print
? t('haSensors.blocksPrints', { entity: reading.entity_id })
: reading.entity_id
}
className={`px-2 py-0.5 text-xs rounded flex items-center gap-1 ${
unreachable
? 'bg-bambu-dark-tertiary/50 text-bambu-gray'
: reading.alerting
? 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400'
: 'bg-bambu-dark-tertiary text-bambu-gray'
}`}
>
<Icon className="w-[var(--pc-i25,0.625rem)] h-[var(--pc-i25,0.625rem)]" />
<span>{reading.name}</span>
<span className="font-medium">{describe(reading)}</span>
</span>
);
})}
</div>
</div>
);
}