PrinterHASensorRow.tsx 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. import { useQuery } from '@tanstack/react-query';
  2. import {
  3. Activity,
  4. AlertTriangle,
  5. DoorClosed,
  6. DoorOpen,
  7. Droplets,
  8. Gauge,
  9. Lock,
  10. LockOpen,
  11. Thermometer,
  12. Wind,
  13. } from 'lucide-react';
  14. import type { LucideIcon } from 'lucide-react';
  15. import { useTranslation } from 'react-i18next';
  16. import { api } from '../api/client';
  17. import type { PrinterHASensorReading } from '../api/client';
  18. /**
  19. * The Home Assistant sensors bound to a printer, on its card (#1148, #448).
  20. *
  21. * Read-only by design — these are contacts and thermometers, not switches, so
  22. * nothing here is clickable. The sibling "HA:" row above handles the entities
  23. * you can actually operate.
  24. */
  25. // Home Assistant's own device_class decides the wording, so a door reads
  26. // "Open"/"Closed" rather than the "on"/"off" the API actually carries. Classes
  27. // absent from this map fall through to on/off, which is what HA itself shows
  28. // for a binary_sensor with no class.
  29. const BINARY_LABELS: Record<string, { on: string; off: string }> = {
  30. door: { on: 'open', off: 'closed' },
  31. garage_door: { on: 'open', off: 'closed' },
  32. window: { on: 'open', off: 'closed' },
  33. opening: { on: 'open', off: 'closed' },
  34. lock: { on: 'unlocked', off: 'locked' },
  35. motion: { on: 'detected', off: 'clear' },
  36. occupancy: { on: 'detected', off: 'clear' },
  37. presence: { on: 'detected', off: 'clear' },
  38. smoke: { on: 'detected', off: 'clear' },
  39. gas: { on: 'detected', off: 'clear' },
  40. moisture: { on: 'wet', off: 'dry' },
  41. problem: { on: 'problem', off: 'ok' },
  42. safety: { on: 'problem', off: 'ok' },
  43. running: { on: 'running', off: 'stopped' },
  44. };
  45. const ICONS: Record<string, LucideIcon> = {
  46. door: DoorOpen,
  47. garage_door: DoorOpen,
  48. window: DoorOpen,
  49. opening: DoorOpen,
  50. lock: LockOpen,
  51. temperature: Thermometer,
  52. humidity: Droplets,
  53. moisture: Droplets,
  54. motion: Activity,
  55. occupancy: Activity,
  56. presence: Activity,
  57. smoke: AlertTriangle,
  58. gas: AlertTriangle,
  59. problem: AlertTriangle,
  60. safety: AlertTriangle,
  61. running: Wind,
  62. };
  63. function iconFor(reading: PrinterHASensorReading): LucideIcon {
  64. const deviceClass = reading.device_class ?? '';
  65. // A closed door wants the closed-door glyph — the map is keyed by class, so
  66. // the two states that have a distinct "off" icon are special-cased here.
  67. if (reading.state === 'off') {
  68. if (ICONS[deviceClass] === DoorOpen) return DoorClosed;
  69. if (ICONS[deviceClass] === LockOpen) return Lock;
  70. }
  71. return ICONS[deviceClass] ?? (reading.kind === 'numeric' ? Gauge : Activity);
  72. }
  73. interface Props {
  74. printerId: number;
  75. }
  76. export function PrinterHASensorRow({ printerId }: Props) {
  77. const { t } = useTranslation();
  78. const { data: readings } = useQuery({
  79. queryKey: ['haSensorReadings', printerId],
  80. queryFn: () => api.getHASensorReadings(printerId),
  81. // Served from the backend poller's cache, so this costs a local request
  82. // and never a Home Assistant round trip. Matched to the poller's own
  83. // cadence — refetching faster would only re-read the same reading.
  84. refetchInterval: 15000,
  85. });
  86. if (!readings?.length) return null;
  87. const describe = (reading: PrinterHASensorReading): string => {
  88. if (!reading.reachable || reading.state === null) return t('haSensors.unavailable');
  89. if (reading.kind === 'numeric') {
  90. if (reading.value === null) return reading.state;
  91. return reading.unit ? `${reading.value} ${reading.unit}` : String(reading.value);
  92. }
  93. const labels = BINARY_LABELS[reading.device_class ?? ''];
  94. const key = labels ? labels[reading.state === 'on' ? 'on' : 'off'] : reading.state;
  95. return t(`haSensors.states.${key}`, { defaultValue: key });
  96. };
  97. return (
  98. <div className="flex items-center gap-2 mt-2">
  99. <Gauge className="w-[var(--pc-i35,0.875rem)] h-[var(--pc-i35,0.875rem)] text-blue-600 dark:text-blue-400 flex-shrink-0" />
  100. <span className="text-xs text-bambu-gray">{t('haSensors.label')}</span>
  101. <div className="h-[2px] w-5 bg-bambu-dark-tertiary/50" />
  102. <div className="flex flex-wrap gap-1">
  103. {readings.map((reading) => {
  104. const Icon = iconFor(reading);
  105. const unreachable = !reading.reachable || reading.state === null;
  106. return (
  107. <span
  108. key={reading.id}
  109. title={
  110. reading.block_print
  111. ? t('haSensors.blocksPrints', { entity: reading.entity_id })
  112. : reading.entity_id
  113. }
  114. className={`px-2 py-0.5 text-xs rounded flex items-center gap-1 ${
  115. unreachable
  116. ? 'bg-bambu-dark-tertiary/50 text-bambu-gray'
  117. : reading.alerting
  118. ? 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400'
  119. : 'bg-bambu-dark-tertiary text-bambu-gray'
  120. }`}
  121. >
  122. <Icon className="w-[var(--pc-i25,0.625rem)] h-[var(--pc-i25,0.625rem)]" />
  123. <span>{reading.name}</span>
  124. <span className="font-medium">{describe(reading)}</span>
  125. </span>
  126. );
  127. })}
  128. </div>
  129. </div>
  130. );
  131. }