-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathair-quality-card.js
More file actions
2517 lines (2300 loc) · 122 KB
/
air-quality-card.js
File metadata and controls
2517 lines (2300 loc) · 122 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* Air Quality Card v2.9.1
* A custom Home Assistant card for air quality visualization
* Thresholds based on WHO 2021 guidelines and ASHRAE standards
*
* https://github.com/KadenThomp36/air-quality-card
*/
const CARD_VERSION = '2.9.1';
// Shared color palettes for the 5-tier color scale used across metrics.
const SCALE_AIRQUALITY = ['#4caf50', '#8bc34a', '#ffc107', '#ff9800', '#f44336']; // green → red
const SCALE_TEMPERATURE = ['#2196f3', '#03a9f4', '#4caf50', '#ff9800', '#f44336']; // blue → red
const SCALE_HUMIDITY = ['#ff9800', '#8bc34a', '#4caf50', '#8bc34a', '#ff9800']; // bell
// Per-metric thresholds, color scale, and status labels. Users may override
// the thresholds via config (e.g. `co2_thresholds: [500, 700, 900, 1200]`).
// Defaults follow WHO 2021 Air Quality Guidelines and ASHRAE standards.
//
// Each `defaultThresholds` array has exactly 4 ascending values defining 5
// tiers. The corresponding `colors`/`labels` arrays have exactly 5 entries.
const METRIC_DEFS = {
co: { defaultThresholds: [4, 9, 35, 100], colors: SCALE_AIRQUALITY, labels: ['Safe', 'Low', 'Moderate', 'High', 'Dangerous'] },
co2: { defaultThresholds: [600, 800, 1000, 1500], colors: SCALE_AIRQUALITY, labels: ['Excellent', 'Good', 'Moderate', 'Elevated', 'Poor'] },
pm25: { defaultThresholds: [5, 15, 25, 35], colors: SCALE_AIRQUALITY, labels: ['Excellent', 'Good', 'Moderate', 'Elevated', 'Poor'] },
pm10: { defaultThresholds: [15, 45, 75, 150], colors: SCALE_AIRQUALITY, labels: ['Excellent', 'Good', 'Moderate', 'Elevated', 'Poor'] },
pm1: { defaultThresholds: [5, 15, 25, 35], colors: SCALE_AIRQUALITY, labels: ['Excellent', 'Good', 'Moderate', 'Elevated', 'Poor'] },
pm03: { defaultThresholds: [500, 1000, 3000, 5000], colors: SCALE_AIRQUALITY, labels: ['Clean', 'Good', 'Moderate', 'Elevated', 'Poor'] },
pm4: { defaultThresholds: [10, 25, 37.5, 50], colors: SCALE_AIRQUALITY, labels: ['Excellent', 'Good', 'Moderate', 'Elevated', 'Poor'] },
hcho: { defaultThresholds: [20, 50, 100, 200], colors: SCALE_AIRQUALITY, labels: ['Excellent', 'Good', 'Moderate', 'Elevated', 'Poor'] },
nox: { defaultThresholds: [20, 50, 150, 250], colors: SCALE_AIRQUALITY, labels: ['Excellent', 'Good', 'Moderate', 'Elevated', 'Poor'] },
radon: { defaultThresholds: [48, 100, 148, 300], colors: SCALE_AIRQUALITY, labels: ['Excellent', 'Good', 'Elevated', 'High', 'Dangerous'] },
humidity: { defaultThresholds: [30, 40, 50, 60], colors: SCALE_HUMIDITY, labels: ['Too Dry', 'Dry', 'Comfortable', 'Humid', 'Too Humid'] },
// tVOC and temperature defaults depend on mode/unit and are computed at call time.
tvoc_ppb: { defaultThresholds: [100, 300, 500, 1000], colors: SCALE_AIRQUALITY, labels: ['Excellent', 'Good', 'Moderate', 'Elevated', 'Poor'] },
tvoc_index: { defaultThresholds: [100, 150, 250, 400], colors: SCALE_AIRQUALITY, labels: ['Excellent', 'Good', 'Moderate', 'Elevated', 'Poor'] },
temp_c: { defaultThresholds: [18, 20, 22, 24], colors: SCALE_TEMPERATURE, labels: ['Cold', 'Cool', 'Comfortable', 'Warm', 'Hot'] },
temp_f: { defaultThresholds: [65, 68, 72, 76], colors: SCALE_TEMPERATURE, labels: ['Cold', 'Cool', 'Comfortable', 'Warm', 'Hot'] }
};
// Embedded translations. Spanish/French/German contributed by @b0rv3g4r4 on
// GitHub PR #11 (with thanks). English is the baseline and the fallback for
// any key missing in a translated locale. To add a language: copy the `en`
// block, rename the key, translate the values, keep the structure identical.
const TRANSLATIONS = {
en: {
status: {
excellent: 'Excellent', good: 'Good', moderate: 'Moderate', fair: 'Fair',
poor: 'Poor', very_poor: 'Very Poor', extremely_poor: 'Extremely Poor', dangerous: 'Dangerous'
},
recommendation: {
all_good: 'All Good', ventilate_now: 'Ventilate Now', run_air_purifier: 'Run Air Purifier',
consider_air_purifier: 'Consider Air Purifier', open_window: 'Open Window',
air_purifier_ventilate: 'Air Purifier + Ventilate', co_danger: 'CO Danger — Leave Area',
co_warning: 'CO Warning — Ventilate Now', co_elevated: 'CO Elevated — Ventilate',
consider_ventilating: 'Consider Ventilating', keep_windows_closed: 'Keep Windows Closed',
too_dry: 'Too Dry', too_humid: 'Too Humid', ventilate_formaldehyde: 'Ventilate — Formaldehyde',
ventilate_vocs: 'Ventilate — VOCs Elevated'
},
subtitle: {
air_quality_healthy: 'Air quality is within healthy limits',
co_danger: 'CO at {value} ppm — dangerous levels detected', co_danger_unknown: 'Dangerous CO levels',
co_warning: 'CO at {value} ppm — open all windows immediately', co_warning_unknown: 'High CO levels',
co_elevated: 'CO at {value} ppm — improve ventilation', co_elevated_unknown: 'CO levels elevated',
purifier_pm25: 'PM2.5 at {value} μg/m³ - filter the air',
purifier_pm10: 'PM10 at {value} μg/m³ - filter the air',
purifier_generic: 'Particulate levels elevated',
consider_purifier_pm10: 'PM10 at {value} μg/m³',
open_window_co2: 'CO₂ at {value} ppm - fresh air needed',
purifier_ventilate: 'CO₂: {co2} ppm, PM2.5: {pm25} μg/m³',
ventilate_now_co2: 'CO₂ at {value} ppm - may affect focus',
ventilate_formaldehyde: 'HCHO at {value} ppb - ventilation needed',
ventilate_formaldehyde_unknown: 'Formaldehyde levels elevated',
ventilate_vocs: 'tVOC at {value} ppb - ventilation needed',
ventilate_vocs_unknown: 'VOC levels elevated',
too_dry: 'Humidity at {value}% - consider humidifier',
too_humid: 'Humidity at {value}% - ventilate',
consider_ventilating_co2: 'CO₂ at {value} ppm',
consider_ventilating_pm25: 'PM2.5 at {value} μg/m³',
consider_ventilating_generic: 'Slightly elevated levels',
keep_closed_outdoor_pm25_poor: 'Outdoor PM2.5 at {value} μg/m³ - poor outdoor air',
keep_closed_outdoor_pm25: 'Outdoor PM2.5 at {value} μg/m³ - worse than indoor',
keep_closed_outdoor_co2: 'Outdoor CO₂ at {value} ppm - worse than indoor',
keep_closed_generic: 'Outdoor air quality is worse than indoor'
},
radon: {
advisory_danger: 'Radon High - Mitigation Needed',
advisory_warning: 'Radon Above EPA Action Level',
advisory_info: 'Radon - Monitor Closely',
short_term: 'Short-term', long_term: 'Long-term'
},
editor: {
name: 'Card Name', co2_entity: 'CO₂ Sensor', pm25_entity: 'PM2.5 Sensor',
humidity_entity: 'Humidity Sensor', temperature_entity: 'Temperature Sensor',
radon_entity: 'Radon Sensor', radon_longterm_entity: 'Radon Long-Term Sensor',
co_entity: 'CO (Carbon Monoxide) Sensor', hcho_entity: 'Formaldehyde (HCHO) Sensor',
tvoc_entity: 'tVOC Sensor', pm4_entity: 'PM4 Sensor', nox_entity: 'NOx Sensor',
pm1_entity: 'PM1 Sensor', pm10_entity: 'PM10 Sensor', pm03_entity: 'PM0.3 Sensor',
outdoor_co2_entity: 'Outdoor CO₂', outdoor_pm25_entity: 'Outdoor PM2.5',
outdoor_humidity_entity: 'Outdoor Humidity', outdoor_temperature_entity: 'Outdoor Temperature',
outdoor_co_entity: 'Outdoor CO', outdoor_hcho_entity: 'Outdoor HCHO',
outdoor_tvoc_entity: 'Outdoor tVOC', outdoor_pm1_entity: 'Outdoor PM1',
outdoor_pm10_entity: 'Outdoor PM10', outdoor_pm03_entity: 'Outdoor PM0.3',
air_quality_entity: 'Air Quality Index (optional)', hours_to_show: 'Graph History',
temperature_unit: 'Temperature Unit', radon_unit: 'Radon Unit',
tvoc_unit: 'tVOC Measurement Type', language: 'Language',
section_additional: 'Additional Sensors', section_outdoor: 'Outdoor Sensors',
section_advanced: 'Advanced'
}
},
es: {
status: { excellent: 'Excelente', good: 'Bueno', moderate: 'Moderado', fair: 'Regular', poor: 'Malo', very_poor: 'Muy malo', extremely_poor: 'Extremadamente malo', dangerous: 'Peligroso' },
recommendation: { all_good: 'Todo bien', ventilate_now: 'Ventila ahora', run_air_purifier: 'Enciende el purificador', consider_air_purifier: 'Plantéate usar un purificador', open_window: 'Abre la ventana', air_purifier_ventilate: 'Purificador y ventilación', co_danger: 'Peligro por CO — evacúa la zona', co_warning: 'Alerta de CO — ventila ahora', co_elevated: 'CO elevado — ventila', consider_ventilating: 'Considera ventilar', keep_windows_closed: 'Mantén las ventanas cerradas', too_dry: 'Demasiado seco', too_humid: 'Demasiado húmedo', ventilate_formaldehyde: 'Ventila — formaldehído', ventilate_vocs: 'Ventila — COV elevados' },
subtitle: { air_quality_healthy: 'La calidad del aire está dentro de límites saludables', co_danger: 'CO en {value} ppm — niveles peligrosos detectados', co_danger_unknown: 'Niveles de CO peligrosos', co_warning: 'CO en {value} ppm — abre todas las ventanas de inmediato', co_warning_unknown: 'Niveles de CO altos', co_elevated: 'CO en {value} ppm — mejora la ventilación', co_elevated_unknown: 'Niveles de CO elevados', purifier_pm25: 'PM2.5 en {value} μg/m³ - filtra el aire', purifier_pm10: 'PM10 en {value} μg/m³ - filtra el aire', purifier_generic: 'Niveles elevados de partículas', consider_purifier_pm10: 'PM10 en {value} μg/m³', open_window_co2: 'CO₂ en {value} ppm - hace falta aire fresco', purifier_ventilate: 'CO₂: {co2} ppm, PM2.5: {pm25} μg/m³', ventilate_now_co2: 'CO₂ en {value} ppm - puede afectar a la concentración', ventilate_formaldehyde: 'HCHO en {value} ppb - ventilación necesaria', ventilate_formaldehyde_unknown: 'Niveles de formaldehído elevados', ventilate_vocs: 'tVOC en {value} ppb - ventilación necesaria', ventilate_vocs_unknown: 'Niveles de COV elevados', too_dry: 'Humedad en {value}% - plantéate usar un humidificador', too_humid: 'Humedad en {value}% - ventila', consider_ventilating_co2: 'CO₂ en {value} ppm', consider_ventilating_pm25: 'PM2.5 en {value} μg/m³', consider_ventilating_generic: 'Niveles ligeramente elevados', keep_closed_outdoor_pm25_poor: 'PM2.5 exterior en {value} μg/m³ - mala calidad del aire exterior', keep_closed_outdoor_pm25: 'PM2.5 exterior en {value} μg/m³ - peor que en interior', keep_closed_outdoor_co2: 'CO₂ exterior en {value} ppm - peor que en interior', keep_closed_generic: 'La calidad del aire exterior es peor que en interior' },
radon: { advisory_danger: 'Radón alto — se necesita mitigación', advisory_warning: 'Radón por encima del nivel de acción EPA', advisory_info: 'Radón — monitorear de cerca', short_term: 'Corto plazo', long_term: 'Largo plazo' },
editor: { name: 'Nombre de la tarjeta', co2_entity: 'Sensor de CO₂', pm25_entity: 'Sensor de PM2.5', humidity_entity: 'Sensor de humedad', temperature_entity: 'Sensor de temperatura', radon_entity: 'Sensor de radón', radon_longterm_entity: 'Sensor de radón (largo plazo)', co_entity: 'Sensor de CO (monóxido de carbono)', hcho_entity: 'Sensor de formaldehído (HCHO)', tvoc_entity: 'Sensor de tVOC', pm4_entity: 'Sensor de PM4', nox_entity: 'Sensor de NOx', pm1_entity: 'Sensor de PM1', pm10_entity: 'Sensor de PM10', pm03_entity: 'Sensor de PM0.3', outdoor_co2_entity: 'CO₂ exterior', outdoor_pm25_entity: 'PM2.5 exterior', outdoor_humidity_entity: 'Humedad exterior', outdoor_temperature_entity: 'Temperatura exterior', outdoor_co_entity: 'CO exterior', outdoor_hcho_entity: 'HCHO exterior', outdoor_tvoc_entity: 'tVOC exterior', outdoor_pm1_entity: 'PM1 exterior', outdoor_pm10_entity: 'PM10 exterior', outdoor_pm03_entity: 'PM0.3 exterior', air_quality_entity: 'Índice de calidad del aire (opcional)', hours_to_show: 'Historial del gráfico', temperature_unit: 'Unidad de temperatura', radon_unit: 'Unidad de radón', tvoc_unit: 'Tipo de medición tVOC', language: 'Idioma', section_additional: 'Sensores adicionales', section_outdoor: 'Sensores exteriores', section_advanced: 'Avanzado' }
},
fr: {
status: { excellent: 'Excellent', good: 'Bon', moderate: 'Modéré', fair: 'Passable', poor: 'Mauvais', very_poor: 'Très mauvais', extremely_poor: 'Extrêmement mauvais', dangerous: 'Dangereux' },
recommendation: { all_good: 'Tout va bien', ventilate_now: 'Ventiler maintenant', run_air_purifier: 'Utiliser le purificateur', consider_air_purifier: 'Envisager le purificateur', open_window: 'Ouvrir une fenêtre', air_purifier_ventilate: 'Purificateur + Ventiler', co_danger: 'Danger au CO — évacuer', co_warning: 'Alerte CO — ventiler maintenant', co_elevated: 'CO élevé — ventiler', consider_ventilating: 'Envisager de ventiler', keep_windows_closed: 'Garder les fenêtres fermées', too_dry: 'Trop sec', too_humid: 'Trop humide', ventilate_formaldehyde: 'Ventiler — Formaldéhyde', ventilate_vocs: 'Ventiler — COV élevés' },
subtitle: { air_quality_healthy: "La qualité de l'air est dans les limites saines", co_danger: 'CO à {value} ppm — niveaux dangereux détectés', co_danger_unknown: 'Niveaux de CO dangereux', co_warning: 'CO à {value} ppm — ouvrir toutes les fenêtres immédiatement', co_warning_unknown: 'Niveaux de CO élevés', co_elevated: 'CO à {value} ppm — améliorer la ventilation', co_elevated_unknown: 'Niveaux de CO élevés', purifier_pm25: "PM2.5 à {value} μg/m³ - filtrer l'air", purifier_pm10: "PM10 à {value} μg/m³ - filtrer l'air", purifier_generic: 'Niveaux de particules élevés', consider_purifier_pm10: 'PM10 à {value} μg/m³', open_window_co2: 'CO₂ à {value} ppm - air frais nécessaire', purifier_ventilate: 'CO₂: {co2} ppm, PM2.5: {pm25} μg/m³', ventilate_now_co2: 'CO₂ à {value} ppm - peut affecter la concentration', ventilate_formaldehyde: 'HCHO à {value} ppb - ventilation nécessaire', ventilate_formaldehyde_unknown: 'Niveaux de formaldéhyde élevés', ventilate_vocs: 'tVOC à {value} ppb - ventilation nécessaire', ventilate_vocs_unknown: 'Niveaux de COV élevés', too_dry: 'Humidité à {value}% - utiliser un humidificateur', too_humid: 'Humidité à {value}% - ventiler', consider_ventilating_co2: 'CO₂ à {value} ppm', consider_ventilating_pm25: 'PM2.5 à {value} μg/m³', consider_ventilating_generic: 'Niveaux légèrement élevés', keep_closed_outdoor_pm25_poor: 'PM2.5 extérieur à {value} μg/m³ - mauvaise qualité extérieure', keep_closed_outdoor_pm25: "PM2.5 extérieur à {value} μg/m³ - pire qu'à l'intérieur", keep_closed_outdoor_co2: "CO₂ extérieur à {value} ppm - pire qu'à l'intérieur", keep_closed_generic: "La qualité de l'air extérieur est pire qu'à l'intérieur" },
radon: { advisory_danger: 'Radon élevé — mitigation nécessaire', advisory_warning: "Radon au-dessus du niveau d'action EPA", advisory_info: 'Radon — surveiller de près', short_term: 'Court terme', long_term: 'Long terme' },
editor: { name: 'Nom de la carte', co2_entity: 'Capteur CO₂', pm25_entity: 'Capteur PM2.5', humidity_entity: "Capteur d'humidité", temperature_entity: 'Capteur de température', radon_entity: 'Capteur de radon', radon_longterm_entity: 'Capteur de radon (long terme)', co_entity: 'Capteur CO (Monoxyde de carbone)', hcho_entity: 'Capteur Formaldéhyde (HCHO)', tvoc_entity: 'Capteur tVOC', pm4_entity: 'Capteur PM4', nox_entity: 'Capteur NOx', pm1_entity: 'Capteur PM1', pm10_entity: 'Capteur PM10', pm03_entity: 'Capteur PM0.3', outdoor_co2_entity: 'CO₂ extérieur', outdoor_pm25_entity: 'PM2.5 extérieur', outdoor_humidity_entity: 'Humidité extérieure', outdoor_temperature_entity: 'Température extérieure', outdoor_co_entity: 'CO extérieur', outdoor_hcho_entity: 'HCHO extérieur', outdoor_tvoc_entity: 'tVOC extérieur', outdoor_pm1_entity: 'PM1 extérieur', outdoor_pm10_entity: 'PM10 extérieur', outdoor_pm03_entity: 'PM0.3 extérieur', air_quality_entity: "Indice de qualité de l'air (optionnel)", hours_to_show: 'Historique du graphique', temperature_unit: 'Unité de température', radon_unit: 'Unité de radon', tvoc_unit: 'Type de mesure tVOC', language: 'Langue', section_additional: 'Capteurs supplémentaires', section_outdoor: 'Capteurs extérieurs', section_advanced: 'Avancé' }
},
de: {
status: { excellent: 'Ausgezeichnet', good: 'Gut', moderate: 'Mäßig', fair: 'Akzeptabel', poor: 'Schlecht', very_poor: 'Sehr schlecht', extremely_poor: 'Extrem schlecht', dangerous: 'Gefährlich' },
recommendation: { all_good: 'Alles gut', ventilate_now: 'Jetzt lüften', run_air_purifier: 'Luftreiniger einschalten', consider_air_purifier: 'Luftreiniger erwägen', open_window: 'Fenster öffnen', air_purifier_ventilate: 'Luftreiniger + Lüften', co_danger: 'CO-Gefahr — Bereich verlassen', co_warning: 'CO-Warnung — Sofort lüften', co_elevated: 'CO erhöht — Lüften', consider_ventilating: 'Lüften erwägen', keep_windows_closed: 'Fenster geschlossen halten', too_dry: 'Zu trocken', too_humid: 'Zu feucht', ventilate_formaldehyde: 'Lüften — Formaldehyd', ventilate_vocs: 'Lüften — VOC erhöht' },
subtitle: { air_quality_healthy: 'Luftqualität liegt innerhalb gesunder Grenzen', co_danger: 'CO bei {value} ppm — gefährliche Werte erkannt', co_danger_unknown: 'Gefährliche CO-Werte', co_warning: 'CO bei {value} ppm — alle Fenster sofort öffnen', co_warning_unknown: 'Hohe CO-Werte', co_elevated: 'CO bei {value} ppm — Belüftung verbessern', co_elevated_unknown: 'CO-Werte erhöht', purifier_pm25: 'PM2.5 bei {value} μg/m³ - Luft filtern', purifier_pm10: 'PM10 bei {value} μg/m³ - Luft filtern', purifier_generic: 'Partikelwerte erhöht', consider_purifier_pm10: 'PM10 bei {value} μg/m³', open_window_co2: 'CO₂ bei {value} ppm - Frischluft benötigt', purifier_ventilate: 'CO₂: {co2} ppm, PM2.5: {pm25} μg/m³', ventilate_now_co2: 'CO₂ bei {value} ppm - kann Konzentration beeinträchtigen', ventilate_formaldehyde: 'HCHO bei {value} ppb - Lüftung erforderlich', ventilate_formaldehyde_unknown: 'Formaldehydwerte erhöht', ventilate_vocs: 'tVOC bei {value} ppb - Lüftung erforderlich', ventilate_vocs_unknown: 'VOC-Werte erhöht', too_dry: 'Luftfeuchtigkeit bei {value}% - Luftbefeuchter empfohlen', too_humid: 'Luftfeuchtigkeit bei {value}% - Lüften', consider_ventilating_co2: 'CO₂ bei {value} ppm', consider_ventilating_pm25: 'PM2.5 bei {value} μg/m³', consider_ventilating_generic: 'Leicht erhöhte Werte', keep_closed_outdoor_pm25_poor: 'Außen PM2.5 bei {value} μg/m³ - schlechte Außenluft', keep_closed_outdoor_pm25: 'Außen PM2.5 bei {value} μg/m³ - schlechter als innen', keep_closed_outdoor_co2: 'Außen CO₂ bei {value} ppm - schlechter als innen', keep_closed_generic: 'Außenluft ist schlechter als Innenluft' },
radon: { advisory_danger: 'Radon hoch — Minderung erforderlich', advisory_warning: 'Radon über EPA-Eingreifrichtwert', advisory_info: 'Radon — genau beobachten', short_term: 'Kurzfristig', long_term: 'Langfristig' },
editor: { name: 'Kartenname', co2_entity: 'CO₂-Sensor', pm25_entity: 'PM2.5-Sensor', humidity_entity: 'Feuchtigkeitssensor', temperature_entity: 'Temperatursensor', radon_entity: 'Radon-Sensor', radon_longterm_entity: 'Radon-Sensor (Langzeit)', co_entity: 'CO-Sensor (Kohlenmonoxid)', hcho_entity: 'Formaldehyd-Sensor (HCHO)', tvoc_entity: 'tVOC-Sensor', pm4_entity: 'PM4-Sensor', nox_entity: 'NOx-Sensor', pm1_entity: 'PM1-Sensor', pm10_entity: 'PM10-Sensor', pm03_entity: 'PM0.3-Sensor', outdoor_co2_entity: 'Außen CO₂', outdoor_pm25_entity: 'Außen PM2.5', outdoor_humidity_entity: 'Außen Luftfeuchtigkeit', outdoor_temperature_entity: 'Außen Temperatur', outdoor_co_entity: 'Außen CO', outdoor_hcho_entity: 'Außen HCHO', outdoor_tvoc_entity: 'Außen tVOC', outdoor_pm1_entity: 'Außen PM1', outdoor_pm10_entity: 'Außen PM10', outdoor_pm03_entity: 'Außen PM0.3', air_quality_entity: 'Luftqualitätsindex (optional)', hours_to_show: 'Diagrammverlauf', temperature_unit: 'Temperatureinheit', radon_unit: 'Radon-Einheit', tvoc_unit: 'tVOC-Messtyp', language: 'Sprache', section_additional: 'Weitere Sensoren', section_outdoor: 'Außensensoren', section_advanced: 'Erweitert' }
},
pt: {
status: { excellent: 'Excelente', good: 'Bom', moderate: 'Moderado', fair: 'Regular', poor: 'Ruim', very_poor: 'Muito Ruim', extremely_poor: 'Extremamente Ruim', dangerous: 'Perigoso' },
recommendation: { all_good: 'Tudo Bem', ventilate_now: 'Ventile Agora', run_air_purifier: 'Ligar o Purificador', consider_air_purifier: 'Considere um Purificador', open_window: 'Abra a Janela', air_purifier_ventilate: 'Purificador + Ventilação', co_danger: 'Perigo de CO — Saia do Ambiente', co_warning: 'Alerta de CO — Ventile Agora', co_elevated: 'CO Elevado — Ventile', consider_ventilating: 'Considere Ventilar', keep_windows_closed: 'Mantenha as Janelas Fechadas', too_dry: 'Muito Seco', too_humid: 'Muito Úmido', ventilate_formaldehyde: 'Ventile — Formaldeído', ventilate_vocs: 'Ventile — COVs Elevados' },
subtitle: { air_quality_healthy: 'A qualidade do ar está dentro dos limites saudáveis', co_danger: 'CO em {value} ppm — níveis perigosos detectados', co_danger_unknown: 'Níveis perigosos de CO', co_warning: 'CO em {value} ppm — abra todas as janelas imediatamente', co_warning_unknown: 'Níveis altos de CO', co_elevated: 'CO em {value} ppm — melhore a ventilação', co_elevated_unknown: 'Níveis de CO elevados', purifier_pm25: 'PM2.5 em {value} μg/m³ - filtre o ar', purifier_pm10: 'PM10 em {value} μg/m³ - filtre o ar', purifier_generic: 'Níveis de partículas elevados', consider_purifier_pm10: 'PM10 em {value} μg/m³', open_window_co2: 'CO₂ em {value} ppm - ar fresco necessário', purifier_ventilate: 'CO₂: {co2} ppm, PM2.5: {pm25} μg/m³', ventilate_now_co2: 'CO₂ em {value} ppm - pode afetar a concentração', ventilate_formaldehyde: 'HCHO em {value} ppb - ventilação necessária', ventilate_formaldehyde_unknown: 'Níveis de formaldeído elevados', ventilate_vocs: 'tVOC em {value} ppb - ventilação necessária', ventilate_vocs_unknown: 'Níveis de COVs elevados', too_dry: 'Umidade em {value}% - considere um umidificador', too_humid: 'Umidade em {value}% - ventile', consider_ventilating_co2: 'CO₂ em {value} ppm', consider_ventilating_pm25: 'PM2.5 em {value} μg/m³', consider_ventilating_generic: 'Níveis levemente elevados', keep_closed_outdoor_pm25_poor: 'PM2.5 externo em {value} μg/m³ - ar externo de má qualidade', keep_closed_outdoor_pm25: 'PM2.5 externo em {value} μg/m³ - pior que o ar interno', keep_closed_outdoor_co2: 'CO₂ externo em {value} ppm - pior que o ar interno', keep_closed_generic: 'A qualidade do ar externo é pior que o interno' },
radon: { advisory_danger: 'Radônio Alto — Mitigação Necessária', advisory_warning: 'Radônio Acima do Nível de Ação da EPA', advisory_info: 'Radônio — Monitorar de Perto', short_term: 'Curto prazo', long_term: 'Longo prazo' },
editor: { name: 'Nome do Cartão', co2_entity: 'Sensor de CO₂', pm25_entity: 'Sensor de PM2.5', humidity_entity: 'Sensor de Umidade', temperature_entity: 'Sensor de Temperatura', radon_entity: 'Sensor de Radônio', radon_longterm_entity: 'Sensor de Radônio (Longo Prazo)', co_entity: 'Sensor de CO (Monóxido de Carbono)', hcho_entity: 'Sensor de Formaldeído (HCHO)', tvoc_entity: 'Sensor de tVOC', pm4_entity: 'Sensor de PM4', nox_entity: 'Sensor de NOx', pm1_entity: 'Sensor de PM1', pm10_entity: 'Sensor de PM10', pm03_entity: 'Sensor de PM0.3', outdoor_co2_entity: 'CO₂ Externo', outdoor_pm25_entity: 'PM2.5 Externo', outdoor_humidity_entity: 'Umidade Externa', outdoor_temperature_entity: 'Temperatura Externa', outdoor_co_entity: 'CO Externo', outdoor_hcho_entity: 'HCHO Externo', outdoor_tvoc_entity: 'tVOC Externo', outdoor_pm1_entity: 'PM1 Externo', outdoor_pm10_entity: 'PM10 Externo', outdoor_pm03_entity: 'PM0.3 Externo', air_quality_entity: 'Índice de Qualidade do Ar (opcional)', hours_to_show: 'Histórico do Gráfico', temperature_unit: 'Unidade de Temperatura', radon_unit: 'Unidade de Radônio', tvoc_unit: 'Tipo de Medição de tVOC', language: 'Idioma', section_additional: 'Sensores Adicionais', section_outdoor: 'Sensores Externos', section_advanced: 'Avançado' }
}
};
class AirQualityCard extends HTMLElement {
static getConfigElement() {
return document.createElement('air-quality-card-editor');
}
static getStubConfig() {
return {
name: 'Air Quality',
hours_to_show: 24
};
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._config = {};
this._hass = null;
this._rendered = false;
this._history = { co2: [], pm25: [], pm1: [], pm10: [], pm03: [], pm4: [], hcho: [], tvoc: [], nox: [], co: [], radon: [], radon_longterm: [], humidity: [], temperature: [], outdoor_co2: [], outdoor_pm25: [], outdoor_pm1: [], outdoor_pm10: [], outdoor_pm03: [], outdoor_hcho: [], outdoor_tvoc: [], outdoor_co: [], outdoor_humidity: [], outdoor_temperature: [] };
this._historyLoaded = false;
this._graphData = {};
this._isDragging = false;
}
setConfig(config) {
if (!config) throw new Error('Invalid configuration');
const indoorEntityKeys = [
'co2_entity', 'pm25_entity', 'pm1_entity', 'pm10_entity', 'pm03_entity',
'pm4_entity', 'hcho_entity', 'tvoc_entity', 'nox_entity', 'co_entity',
'radon_entity', 'radon_longterm_entity', 'humidity_entity', 'temperature_entity'
];
const outdoorEntityKeys = [
'outdoor_co2_entity', 'outdoor_pm25_entity', 'outdoor_pm1_entity',
'outdoor_pm10_entity', 'outdoor_pm03_entity', 'outdoor_hcho_entity',
'outdoor_tvoc_entity', 'outdoor_co_entity', 'outdoor_humidity_entity',
'outdoor_temperature_entity'
];
const hasIndoor = indoorEntityKeys.some(k => config[k]);
const hasOutdoor = outdoorEntityKeys.some(k => config[k]);
if (!hasIndoor && !hasOutdoor) {
throw new Error('Please configure at least one sensor entity');
}
this._config = {
name: 'Air Quality',
hours_to_show: 24,
temperature_unit: 'auto',
radon_unit: 'auto',
show_min_max: false,
display: 'full',
language: 'auto',
...config
};
// Outdoor-only mode: when no indoor entities are set, promote each outdoor
// entity into its primary slot so the existing render pipeline shows it.
// Recommendations are suppressed in this mode since they assume indoor context.
this._outdoorOnly = !hasIndoor;
if (this._outdoorOnly) {
const promotionMap = {
outdoor_co2_entity: 'co2_entity',
outdoor_pm25_entity: 'pm25_entity',
outdoor_pm1_entity: 'pm1_entity',
outdoor_pm10_entity: 'pm10_entity',
outdoor_pm03_entity: 'pm03_entity',
outdoor_hcho_entity: 'hcho_entity',
outdoor_tvoc_entity: 'tvoc_entity',
outdoor_co_entity: 'co_entity',
outdoor_humidity_entity: 'humidity_entity',
outdoor_temperature_entity: 'temperature_entity'
};
for (const [outdoorKey, primaryKey] of Object.entries(promotionMap)) {
if (this._config[outdoorKey] && !this._config[primaryKey]) {
this._config[primaryKey] = this._config[outdoorKey];
delete this._config[outdoorKey];
}
}
}
this._rendered = false;
this._historyLoaded = false;
}
_getMinMax(data) {
if (!data || !data.length) return null;
let min = data[0].value;
let max = data[0].value;
for (let i = 1; i < data.length; i++) {
if (data[i].value < min) min = data[i].value;
if (data[i].value > max) max = data[i].value;
}
return { min, max };
}
_formatGraphValue(value, unit) {
if (unit === 'pCi/L') return value.toFixed(1);
if (unit === 'ppm' || unit === 'ppb' || unit === 'p/0.1L' || unit === 'Bq/m³' || unit === '%' || unit === '°F' || unit === '°C') {
return Math.round(value);
}
return value.toFixed(1);
}
// Anchor min/max value labels to the actual data points on the line.
// The position percentages are computed against the SVG's 300×50 viewBox;
// because preserveAspectRatio="none" stretches the SVG to fill the wrapper,
// the same percentage maps cleanly to the wrapper's dimensions.
_updateMinMaxDisplay(graphId, data, colorFn) {
const minMax = this._getMinMax(data);
if (!minMax || minMax.min === minMax.max) {
this._clearMinMaxMarkers(graphId);
return;
}
let minIdx = 0, maxIdx = 0;
for (let i = 1; i < data.length; i++) {
if (data[i].value < data[minIdx].value) minIdx = i;
if (data[i].value > data[maxIdx].value) maxIdx = i;
}
const points = this._graphData[graphId] && this._graphData[graphId].points;
if (!points || !points.length) return;
const wrapper = this.shadowRoot.getElementById(`${graphId}-graph`);
if (!wrapper) return;
this._renderMinMaxMarker(graphId, 'max', points[maxIdx], colorFn(minMax.max), this._formatGraphValue(minMax.max, this._graphData[graphId].unit));
this._renderMinMaxMarker(graphId, 'min', points[minIdx], colorFn(minMax.min), this._formatGraphValue(minMax.min, this._graphData[graphId].unit));
}
_renderMinMaxMarker(graphId, kind, point, color, valueStr) {
if (!point) return;
const wrapper = this.shadowRoot.getElementById(`${graphId}-graph`);
if (!wrapper) return;
const id = `${graphId}-minmax-${kind}`;
let marker = this.shadowRoot.getElementById(id);
if (!marker) {
marker = document.createElement('div');
marker.id = id;
marker.className = `minmax-marker minmax-marker--${kind}`;
wrapper.appendChild(marker);
}
// Y position: point.y is in the 0..50 SVG coordinate system; convert to %
const leftPct = (point.x / 300) * 100;
const topPct = (point.y / 50) * 100;
// Flip the label across the chart vertical midline so it can't get
// clipped by the chart's top/bottom edge: anchor the label on the
// opposite side of the data point from where it sits.
const placeBelow = point.y < 25;
// Same idea for horizontal: when very close to an edge, anchor the
// label to that edge instead of centering on the point.
let anchor = 'center';
if (leftPct < 12) anchor = 'left';
else if (leftPct > 88) anchor = 'right';
marker.style.left = `${leftPct}%`;
marker.style.top = `${topPct}%`;
marker.style.color = color;
marker.dataset.place = placeBelow ? 'below' : 'above';
marker.dataset.anchor = anchor;
marker.textContent = valueStr;
}
_clearMinMaxMarkers(graphId) {
['min', 'max'].forEach(kind => {
const el = this.shadowRoot.getElementById(`${graphId}-minmax-${kind}`);
if (el) el.remove();
});
}
_isCompact() {
return this._config.display === 'compact';
}
// Resolve the active translation language. Order of precedence:
// 1. Explicit `language` config (when not 'auto')
// 2. hass.locale.language (modern HA, since the deprecation of hass.language)
// 3. hass.language (older HA versions, deprecated but still present)
// 4. 'en'
// Falls back to 'en' if the resolved code isn't shipped.
_resolveLanguage() {
const explicit = this._config.language;
let lang;
if (explicit && explicit !== 'auto') {
lang = explicit;
} else {
lang = this._hass?.locale?.language || this._hass?.language || 'en';
}
const code = String(lang).split('-')[0].toLowerCase();
return TRANSLATIONS[code] ? code : 'en';
}
// Look up a translated string: _t('status', 'good') → 'Bueno' (es).
// Falls back to English, then to the literal key if neither is found.
_t(group, key) {
const lang = this._resolveLanguage();
const langPack = TRANSLATIONS[lang] && TRANSLATIONS[lang][group];
if (langPack && langPack[key] !== undefined) return langPack[key];
const enPack = TRANSLATIONS.en[group];
if (enPack && enPack[key] !== undefined) return enPack[key];
return key;
}
// Translate with {placeholder} interpolation: _ts('subtitle', 'co_danger', { value: 42 })
_ts(group, key, vars) {
let str = this._t(group, key);
if (vars) {
for (const k of Object.keys(vars)) {
str = str.replace(new RegExp('\\{' + k + '\\}', 'g'), vars[k]);
}
}
return str;
}
set hass(hass) {
this._hass = hass;
if (!this._rendered) {
this._initialRender();
this._rendered = true;
// Compact mode doesn't draw graphs, so skip the history fetch
if (!this._isCompact()) this._loadHistory();
}
this._updateStates();
}
// Resolve the metric display order. User's `order` wins; anything they
// didn't list is appended in the default order so users never lose a
// configured metric by forgetting to mention it.
_getMetricOrder() {
const all = ['co', 'radon', 'co2', 'pm25', 'pm10', 'pm1', 'pm03', 'pm4', 'hcho', 'tvoc', 'nox', 'humidity', 'temperature'];
if (!Array.isArray(this._config.order) || !this._config.order.length) return all;
const valid = this._config.order.filter(m => all.includes(m));
const remaining = all.filter(m => !valid.includes(m));
return [...valid, ...remaining];
}
// Reorder graph cards via flexbox `order` rather than rebuilding the DOM —
// .graphs is already display:flex, so setting style.order on each container
// is enough to reflow them visually.
_applyMetricOrder() {
if (!Array.isArray(this._config.order) || !this._config.order.length) return;
this._getMetricOrder().forEach((metric, idx) => {
const container = this.shadowRoot.getElementById(`${metric}-graph-container`);
if (container) container.style.order = idx;
});
}
getCardSize() {
if (this._isCompact()) return 1;
let size = 3; // Base size for header and recommendation
if (this._config.co_entity) size += 1;
if (this._config.radon_entity) size += 1;
if (this._config.co2_entity) size += 1;
if (this._config.pm25_entity) size += 1;
if (this._config.pm10_entity) size += 1;
if (this._config.pm1_entity) size += 1;
if (this._config.pm03_entity) size += 1;
if (this._config.hcho_entity) size += 1;
if (this._config.tvoc_entity) size += 1;
if (this._config.pm4_entity) size += 1;
if (this._config.nox_entity) size += 1;
if (this._config.humidity_entity) size += 1;
if (this._config.temperature_entity) size += 1;
return size;
}
// Dispatch HA's standard action event for tap/hold/double_tap. HA's action
// handler reads tap_action/hold_action/double_tap_action from the config.
// Pattern documented at developers.home-assistant.io/blog/2023/07/07.
_fireAction(action) {
const actionKey = `${action}_action`;
if (!this._config[actionKey]) return;
const event = new CustomEvent('hass-action', {
bubbles: true,
composed: true,
detail: { config: this._config, action }
});
this.dispatchEvent(event);
}
async _loadHistory() {
if (!this._hass || this._historyLoaded) return;
const endTime = new Date();
const startTime = new Date(endTime.getTime() - (this._config.hours_to_show * 60 * 60 * 1000));
// Persist the requested time window so _renderGraph can plot points by
// timestamp (not by data-point index) and so axis labels reflect the
// configured window even when data doesn't span it.
this._timeWindow = { start: startTime.getTime(), end: endTime.getTime() };
try {
const promises = [];
const keys = [];
if (this._config.co_entity) {
promises.push(this._fetchHistory(this._config.co_entity, startTime, endTime));
keys.push('co');
}
if (this._config.radon_entity) {
promises.push(this._fetchHistory(this._config.radon_entity, startTime, endTime));
keys.push('radon');
}
if (this._config.radon_longterm_entity) {
promises.push(this._fetchHistory(this._config.radon_longterm_entity, startTime, endTime));
keys.push('radon_longterm');
}
if (this._config.co2_entity) {
promises.push(this._fetchHistory(this._config.co2_entity, startTime, endTime));
keys.push('co2');
}
if (this._config.pm25_entity) {
promises.push(this._fetchHistory(this._config.pm25_entity, startTime, endTime));
keys.push('pm25');
}
if (this._config.pm10_entity) {
promises.push(this._fetchHistory(this._config.pm10_entity, startTime, endTime));
keys.push('pm10');
}
if (this._config.pm1_entity) {
promises.push(this._fetchHistory(this._config.pm1_entity, startTime, endTime));
keys.push('pm1');
}
if (this._config.pm03_entity) {
promises.push(this._fetchHistory(this._config.pm03_entity, startTime, endTime));
keys.push('pm03');
}
if (this._config.hcho_entity) {
promises.push(this._fetchHistory(this._config.hcho_entity, startTime, endTime));
keys.push('hcho');
}
if (this._config.tvoc_entity) {
promises.push(this._fetchHistory(this._config.tvoc_entity, startTime, endTime));
keys.push('tvoc');
}
if (this._config.pm4_entity) {
promises.push(this._fetchHistory(this._config.pm4_entity, startTime, endTime));
keys.push('pm4');
}
if (this._config.nox_entity) {
promises.push(this._fetchHistory(this._config.nox_entity, startTime, endTime));
keys.push('nox');
}
if (this._config.humidity_entity) {
promises.push(this._fetchHistory(this._config.humidity_entity, startTime, endTime));
keys.push('humidity');
}
if (this._config.temperature_entity) {
promises.push(this._fetchHistory(this._config.temperature_entity, startTime, endTime));
keys.push('temperature');
}
// Outdoor sensors
const outdoorSensors = ['co2', 'pm25', 'pm1', 'pm10', 'pm03', 'hcho', 'tvoc', 'co', 'humidity', 'temperature'];
for (const sensor of outdoorSensors) {
const key = `outdoor_${sensor}_entity`;
if (this._config[key]) {
promises.push(this._fetchHistory(this._config[key], startTime, endTime));
keys.push(`outdoor_${sensor}`);
}
}
const results = await Promise.all(promises);
keys.forEach((key, i) => {
this._history[key] = this._processHistory(results[i]);
});
this._historyLoaded = true;
this._renderGraphs();
} catch (e) {
console.warn('Air Quality Card: Failed to load history:', e);
}
}
async _fetchHistory(entityId, startTime, endTime) {
if (!entityId) return [];
const uri = `history/period/${startTime.toISOString()}?filter_entity_id=${entityId}&end_time=${endTime.toISOString()}&minimal_response&no_attributes`;
const response = await this._hass.callApi('GET', uri);
return response?.[0] || [];
}
_processHistory(history) {
return history
.filter(item => item.state && !isNaN(parseFloat(item.state)))
.map(item => ({
time: new Date(item.last_changed).getTime(),
value: parseFloat(item.state)
}));
}
_getState(entityId) {
if (!entityId) return 'unknown';
return this._hass?.states[entityId]?.state ?? 'unknown';
}
_getNumericState(entityId) {
const state = this._getState(entityId);
return parseFloat(state) || 0;
}
// Generic ascending-tier lookup. `thresholds` is 4 ascending boundaries;
// `tiers` is the 5-element output array (colors, labels, …).
_tieredValue(value, thresholds, tiers) {
for (let i = 0; i < thresholds.length; i++) {
if (value < thresholds[i]) return tiers[i];
}
return tiers[tiers.length - 1];
}
// Plot points by timestamp within the configured time window so spikes
// appear at the correct X position even when data is unevenly sampled.
_computeGraphX(timestamp, width, padding) {
if (!this._timeWindow) return padding;
const { start, end } = this._timeWindow;
const span = end - start;
if (span <= 0) return padding;
const ratio = (timestamp - start) / span;
const clamped = Math.max(0, Math.min(1, ratio));
return padding + clamped * (width - 2 * padding);
}
// Resolve the active threshold array for a metric — config override first,
// then the metric's default. The metric key matches the key in METRIC_DEFS
// (e.g. 'co2' uses `co2_thresholds`; 'temp_c' uses `temperature_thresholds`).
_metricThresholds(metric) {
const overrideKey = {
co: 'co_thresholds', co2: 'co2_thresholds', pm25: 'pm25_thresholds',
pm10: 'pm10_thresholds', pm1: 'pm1_thresholds', pm03: 'pm03_thresholds',
pm4: 'pm4_thresholds', hcho: 'hcho_thresholds', nox: 'nox_thresholds',
radon: 'radon_thresholds', humidity: 'humidity_thresholds',
tvoc_ppb: 'tvoc_thresholds', tvoc_index: 'tvoc_thresholds',
temp_c: 'temperature_thresholds', temp_f: 'temperature_thresholds'
}[metric];
const override = overrideKey && this._config[overrideKey];
if (Array.isArray(override) && override.length === 4 && override.every(n => typeof n === 'number')) {
return override;
}
return METRIC_DEFS[metric].defaultThresholds;
}
// Backward-compat proxies for the bug-fix branch's named status helpers.
// The canonical entry point is _getMetricStatus; these exist so existing
// tests (and any third-party code) keep working unchanged.
_getCO2Status(value) { return this._getMetricStatus('co2', value); }
_getHumidityStatus(value) { return this._getMetricStatus('humidity', value); }
_getTempStatus(value) { return this._getMetricStatus(this._tempMetric(), value); }
_getMetricColor(metric, value) {
return this._tieredValue(value, this._metricThresholds(metric), METRIC_DEFS[metric].colors);
}
_getMetricStatus(metric, value) {
return this._tieredValue(value, this._metricThresholds(metric), METRIC_DEFS[metric].labels);
}
_getCO2Color(value) { return this._getMetricColor('co2', value); }
_getPM25Color(value) { return this._getMetricColor('pm25', value); }
_getHCHOColor(value) { return this._getMetricColor('hcho', value); }
_getPM4Color(value) { return this._getMetricColor('pm4', value); }
_getNOxColor(value) { return this._getMetricColor('nox', value); }
_getHumidityColor(value) { return this._getMetricColor('humidity', value); }
_getPM1Color(value) { return this._getMetricColor('pm1', value); }
_getPM10Color(value) { return this._getMetricColor('pm10', value); }
_getPM03Color(value) { return this._getMetricColor('pm03', value); }
_getCOColor(value) { return this._getMetricColor('co', value); }
_getRadonColor(bq) { return this._getMetricColor('radon', bq); }
_isVOCIndex() {
if (this._config.tvoc_unit && this._config.tvoc_unit !== 'auto') {
return this._config.tvoc_unit === 'index';
}
// Auto-detect from entity unit_of_measurement
if (this._hass && this._config.tvoc_entity) {
const uom = this._hass.states[this._config.tvoc_entity]?.attributes?.unit_of_measurement;
if (uom === undefined || uom === null || uom === '' || uom?.toLowerCase() === 'voc index') return true;
if (uom === 'ppb' || uom === 'mg/m³') return false;
}
return false;
}
_getTVOCUnit() {
return this._isVOCIndex() ? '' : 'ppb';
}
_tvocMetric() {
return this._isVOCIndex() ? 'tvoc_index' : 'tvoc_ppb';
}
_getTVOCColor(value) {
return this._getMetricColor(this._tvocMetric(), value);
}
_getRadonUnit() {
const unit = this._config.radon_unit;
if (unit === 'pCi/L') return 'pCi/L';
if (unit === 'Bq/m³') return 'Bq/m³';
// Auto-detect from entity's unit_of_measurement
if (this._config.radon_entity) {
const entityUnit = this._hass?.states[this._config.radon_entity]?.attributes?.unit_of_measurement;
if (entityUnit && entityUnit.toLowerCase().includes('pci')) return 'pCi/L';
}
return 'Bq/m³';
}
_isRadonPciL() {
return this._getRadonUnit() === 'pCi/L';
}
_getRadonBqm3(value) {
if (this._isRadonPciL()) return value * 37;
return value;
}
_formatRadon(value) {
const unit = this._getRadonUnit();
if (unit === 'pCi/L') return `${value.toFixed(1)} pCi/L`;
return `${Math.round(value)} Bq/m³`;
}
_getRadonAdvisory() {
if (!this._config.radon_entity && !this._config.radon_longterm_entity) return null;
const shortRaw = this._config.radon_entity ? this._getNumericState(this._config.radon_entity) : 0;
const longRaw = this._config.radon_longterm_entity ? this._getNumericState(this._config.radon_longterm_entity) : 0;
const shortBq = this._getRadonBqm3(shortRaw);
const longBq = this._getRadonBqm3(longRaw);
const bq = Math.max(shortBq, longBq);
const raw = shortBq >= longBq ? shortRaw : longRaw;
const display = this._formatRadon(raw);
const threshold = this._isRadonPciL() ? '4.0 pCi/L' : '148 Bq/m³';
// Build subtitle with both values when both are configured
const bothConfigured = this._config.radon_entity && this._config.radon_longterm_entity;
const valuesStr = bothConfigured
? `${this._t('radon', 'short_term')}: ${this._formatRadon(shortRaw)}, ${this._t('radon', 'long_term')}: ${this._formatRadon(longRaw)}`
: `Radon at ${display}`;
if (bq >= 300) return {
level: 'danger',
text: this._t('radon', 'advisory_danger'),
subtitle: `${valuesStr} - contact a certified radon mitigator`
};
if (bq >= 148) return {
level: 'warning',
text: this._t('radon', 'advisory_warning'),
subtitle: `${valuesStr} - EPA recommends mitigation above ${threshold}`
};
if (bq >= 100) return {
level: 'info',
text: this._t('radon', 'advisory_info'),
subtitle: `${valuesStr} - approaching action level`
};
return null;
}
_isCelsius() {
const unit = this._config.temperature_unit;
if (unit === 'C') return true;
if (unit === 'F') return false;
// Auto-detect from Home Assistant unit system
try {
return this._hass.config.unit_system.temperature === '°C';
} catch (e) {
return false;
}
}
_getTempUnit() {
return this._isCelsius() ? '°C' : '°F';
}
_tempMetric() {
return this._isCelsius() ? 'temp_c' : 'temp_f';
}
_getTempColor(value) {
return this._getMetricColor(this._tempMetric(), value);
}
_getOverallStatus() {
const co = this._config.co_entity ? this._getNumericState(this._config.co_entity) : 0;
const co2 = this._config.co2_entity ? this._getNumericState(this._config.co2_entity) : 0;
const pm25 = this._config.pm25_entity ? this._getNumericState(this._config.pm25_entity) : 0;
const radonShort = this._config.radon_entity ? this._getRadonBqm3(this._getNumericState(this._config.radon_entity)) : 0;
const radonLong = this._config.radon_longterm_entity ? this._getRadonBqm3(this._getNumericState(this._config.radon_longterm_entity)) : 0;
const radon = Math.max(radonShort, radonLong);
// If air_quality_entity is configured, use it
if (this._config.air_quality_entity) {
const quality = this._getState(this._config.air_quality_entity);
const statusKey = String(quality || '').toLowerCase().replace(/\s+/g, '_');
const translated = this._t('status', statusKey);
// If translation found, use it; otherwise show the raw entity state cleaned up
const display = translated !== statusKey ? translated : String(quality || '').replace('_', ' ');
return { status: display, color: this._getQualityColor(quality) };
}
// CO is a life-safety metric — always takes priority
if (co > 35) return { status: this._t('status', 'dangerous'), color: '#d32f2f' };
if (co > 9) return { status: this._t('status', 'poor'), color: '#f44336' };
// Radon — only degrades status at EPA action level and above
if (radon >= 300) return { status: this._t('status', 'poor'), color: '#f44336' };
if (radon >= 148) return { status: this._t('status', 'fair'), color: '#ff9800' };
// Calculate from CO2 and PM2.5
if (co2 > 1500 || pm25 > 35) return { status: this._t('status', 'poor'), color: '#f44336' };
if (co2 > 1000 || pm25 > 25) return { status: this._t('status', 'fair'), color: '#ff9800' };
if (co2 > 800 || pm25 > 15) return { status: this._t('status', 'moderate'), color: '#ffc107' };
if (co2 > 600 || pm25 > 5) return { status: this._t('status', 'good'), color: '#8bc34a' };
return { status: this._t('status', 'excellent'), color: '#4caf50' };
}
_getQualityColor(quality) {
const colors = {
'good': '#4caf50',
'excellent': '#4caf50',
'moderate': '#8bc34a',
'fair': '#ffc107',
'poor': '#ff9800',
'very_poor': '#f44336',
'very poor': '#f44336',
'extremely_poor': '#b71c1c'
};
return colors[quality?.toLowerCase()] || '#9e9e9e';
}
// Translation-key-based recommendation dispatcher. Used internally for icon
// lookup and subtitle generation. `_getRecommendation()` translates the key
// for display.
_getRecommendationKey() {
// Outdoor-only mode: recommendations assume an indoor context (open window,
// run air purifier, etc.) and are nonsensical when monitoring ambient air.
if (this._outdoorOnly) return null;
const co = this._config.co_entity ? this._getNumericState(this._config.co_entity) : 0;
const co2 = this._config.co2_entity ? this._getNumericState(this._config.co2_entity) : 0;
const pm25 = this._config.pm25_entity ? this._getNumericState(this._config.pm25_entity) : 0;
const pm10 = this._config.pm10_entity ? this._getNumericState(this._config.pm10_entity) : 0;
const hcho = this._config.hcho_entity ? this._getNumericState(this._config.hcho_entity) : 0;
const tvoc = this._config.tvoc_entity ? this._getNumericState(this._config.tvoc_entity) : 0;
const humidity = this._config.humidity_entity ? this._getNumericState(this._config.humidity_entity) : 45;
const outdoorCo2 = this._config.outdoor_co2_entity ? this._getNumericState(this._config.outdoor_co2_entity) : null;
const outdoorPm25 = this._config.outdoor_pm25_entity ? this._getNumericState(this._config.outdoor_pm25_entity) : null;
const outdoorIsWorse = (outdoorPm25 !== null && outdoorPm25 > pm25) || (outdoorCo2 !== null && outdoorCo2 > co2);
// CO life-safety first (never suppressed by outdoor override)
if (co > 100) return 'co_danger';
if (co > 35) return 'co_warning';
let key = 'all_good';
if (co2 > 1500) key = 'ventilate_now';
else if (pm25 > 35) key = 'run_air_purifier';
else if (pm10 > 150) key = 'run_air_purifier';
else if (hcho > 100) key = 'ventilate_formaldehyde';
else if (tvoc > 500) key = 'ventilate_vocs';
else if (pm25 > 25 && co2 > 1000) key = 'air_purifier_ventilate';
else if (pm25 > 25) key = 'run_air_purifier';
else if (pm10 > 75) key = 'consider_air_purifier';
else if (co2 > 1000) key = 'open_window';
else if (co > 9) key = 'co_elevated';
else if (humidity < 30) key = 'too_dry';
else if (humidity > 60) key = 'too_humid';
else if (co2 > 800 || pm25 > 15) key = 'consider_ventilating';
// CO recommendations are intentionally excluded — CO is always life-safety
const ventilationKeys = ['ventilate_now', 'open_window', 'consider_ventilating', 'air_purifier_ventilate', 'ventilate_formaldehyde', 'ventilate_vocs', 'co_elevated'];
if (outdoorIsWorse && ventilationKeys.includes(key)) {
if (pm25 > 25) return 'run_air_purifier';
return 'keep_windows_closed';
}
return key;
}
// Public-facing recommendation as a translated display string. Returns
// null when the key is null (e.g. outdoor-only mode suppresses recs).
_getRecommendation() {
const key = this._getRecommendationKey();
if (!key) return null;
return this._t('recommendation', key);
}
_getRecommendationIcon(rec) {
// Accept either a translation key (preferred for new callers) or an
// English display string (backward-compat with older callers/tests).
const iconByKey = {
all_good: 'mdi:check-circle',
consider_ventilating: 'mdi:information',
open_window: 'mdi:window-open-variant',
run_air_purifier: 'mdi:air-purifier',
consider_air_purifier: 'mdi:air-purifier',
air_purifier_ventilate: 'mdi:alert',
ventilate_now: 'mdi:alert-circle',
ventilate_formaldehyde: 'mdi:alert-circle',
ventilate_vocs: 'mdi:alert-circle',
co_danger: 'mdi:alert-octagon',
co_warning: 'mdi:alert-octagon',
co_elevated: 'mdi:alert',
keep_windows_closed: 'mdi:window-closed-variant',
too_dry: 'mdi:water-percent',
too_humid: 'mdi:water'
};
if (iconByKey[rec]) return iconByKey[rec];
// Backward-compat: translated/English text → reverse to key via en pack.
const enRec = TRANSLATIONS.en.recommendation;
for (const k of Object.keys(enRec)) {
if (enRec[k] === rec) return iconByKey[k] || 'mdi:air-filter';
}
return 'mdi:air-filter';
}
_renderCompact() {
const hasAction = !!this._config.tap_action;
this.shadowRoot.innerHTML = `
<style>
:host {
--aq-excellent: #4caf50;
--aq-good: #8bc34a;
--aq-moderate: #ffc107;
--aq-poor: #ff9800;
--aq-very-poor: #f44336;
--aq-critical: #d32f2f;
}
ha-card.compact {
padding: 12px 16px;
${hasAction ? 'cursor: pointer; transition: background 0.15s ease;' : ''}
}
${hasAction ? `
ha-card.compact:hover {
background: rgba(var(--rgb-primary-text-color, 0, 0, 0), 0.04);
}
` : ''}
.compact-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.title {
font-size: 1.05em;
font-weight: 600;
color: var(--primary-text-color);
}
.status-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 16px;
font-size: 0.85em;
font-weight: 500;
text-transform: capitalize;
color: var(--primary-text-color);
}
.status-badge ha-icon {
--mdc-icon-size: 18px;
}
</style>
<ha-card class="compact">
<div class="compact-row">
<span class="title">${this._config.name}</span>
<div class="status-badge" id="status-badge">
<ha-icon id="status-icon" icon="mdi:leaf"></ha-icon>
<span id="status-text">Loading…</span>
</div>
</div>
</ha-card>
`;
const card = this.shadowRoot.querySelector('ha-card');
if (card && hasAction) {
card.addEventListener('click', () => this._fireAction('tap'));
}
if (card && this._config.hold_action) {
// Distinguish a hold (>500ms) from a tap
let timer = null;
let held = false;
const start = () => {
held = false;
timer = setTimeout(() => { held = true; this._fireAction('hold'); }, 500);
};
const end = () => { if (timer) { clearTimeout(timer); timer = null; } };
card.addEventListener('mousedown', start);
card.addEventListener('mouseup', end);
card.addEventListener('mouseleave', end);
card.addEventListener('touchstart', start, { passive: true });
card.addEventListener('touchend', end);
// Suppress the tap action when a hold fired
card.addEventListener('click', (e) => {
if (held) { e.stopImmediatePropagation(); held = false; }
}, true);
}
}
_initialRender() {
if (this._isCompact()) {
this._renderCompact();
return;
}
const showCO = !!this._config.co_entity;
const showRadon = !!(this._config.radon_entity || this._config.radon_longterm_entity);
const showCO2 = !!this._config.co2_entity;
const showPM25 = !!this._config.pm25_entity;
const showPM10 = !!this._config.pm10_entity;
const showPM1 = !!this._config.pm1_entity;
const showPM03 = !!this._config.pm03_entity;
const showHCHO = !!this._config.hcho_entity;
const showTVOC = !!this._config.tvoc_entity;
const showPM4 = !!this._config.pm4_entity;
const showNOx = !!this._config.nox_entity;
const showHumidity = !!this._config.humidity_entity;
const showTemp = !!this._config.temperature_entity;
this.shadowRoot.innerHTML = `
<style>
:host {
--aq-excellent: #4caf50;
--aq-good: #8bc34a;
--aq-moderate: #ffc107;
--aq-poor: #ff9800;
--aq-very-poor: #f44336;
--aq-critical: #d32f2f;
}
.card {
background: var(--ha-card-background, var(--card-background-color, #fff));
border-radius: var(--ha-card-border-radius, 12px);
padding: 16px;
color: var(--primary-text-color);
font-family: var(--paper-font-body1_-_font-family);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.title {
font-size: 1.1em;
font-weight: 600;
}
.status-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 16px;
font-size: 0.8em;
font-weight: 500;
text-transform: capitalize;
}
.recommendation {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 10px;
margin-bottom: 14px;
background: var(--secondary-background-color);
}