Compare commits

...

3 commits
v2.1.0 ... main

Author SHA1 Message Date
903c329083 Add HCHO and tVOC charts
Some checks are pending
Validate / validate-hacs (push) Waiting to run
2026-02-16 17:41:02 +01:00
KadenThomp36
d447a5e685 Add HACS validation GitHub Action
Required for HACS default repository submission.
2026-02-11 13:41:36 -05:00
KadenThomp36
1667470bc7 Add Celsius/Fahrenheit temperature unit toggle (v2.2.0)
- New `temperature_unit` config option ('C' or 'F', default 'F')
- Temperature thresholds, graph ranges, and status labels adapt to unit
- Toggle available in both modern and fallback visual editors
2026-02-11 11:29:35 -05:00
3 changed files with 180 additions and 12 deletions

18
.github/workflows/validate.yml vendored Normal file
View file

@ -0,0 +1,18 @@
name: Validate
on:
push:
pull_request:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
jobs:
validate-hacs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: HACS Validation
uses: hacs/action@main
with:
category: plugin

View file

@ -55,6 +55,7 @@ temperature_entity: sensor.air_quality_temperature
air_quality_entity: sensor.air_quality_index air_quality_entity: sensor.air_quality_index
recommendation_entity: sensor.air_quality_recommendation recommendation_entity: sensor.air_quality_recommendation
hours_to_show: 24 hours_to_show: 24
temperature_unit: C
``` ```
### Configuration Options ### Configuration Options
@ -69,6 +70,7 @@ hours_to_show: 24
| `air_quality_entity` | string | No | - | Overall air quality index entity | | `air_quality_entity` | string | No | - | Overall air quality index entity |
| `recommendation_entity` | string | No | - | Recommendation template sensor | | `recommendation_entity` | string | No | - | Recommendation template sensor |
| `hours_to_show` | number | No | 24 | Hours of history to display (1-168) | | `hours_to_show` | number | No | 24 | Hours of history to display (1-168) |
| `temperature_unit` | string | No | "F" | Temperature unit: "F" (Fahrenheit) or "C" (Celsius) |
## Recommendation Sensor ## Recommendation Sensor

View file

@ -6,7 +6,7 @@
* https://github.com/KadenThomp36/air-quality-card * https://github.com/KadenThomp36/air-quality-card
*/ */
const CARD_VERSION = '2.1.0'; const CARD_VERSION = '2.2.0';
class AirQualityCard extends HTMLElement { class AirQualityCard extends HTMLElement {
// Visual editor using getConfigForm (preferred modern approach) // Visual editor using getConfigForm (preferred modern approach)
@ -21,6 +21,13 @@ class AirQualityCard extends HTMLElement {
{ name: 'pm25_entity', selector: { entity: { domain: 'sensor' } } }, { name: 'pm25_entity', selector: { entity: { domain: 'sensor' } } },
] ]
}, },
{
type: 'grid',
schema: [
{ name: 'hcho_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'tvoc_entity', selector: { entity: { domain: 'sensor' } } },
]
},
{ {
type: 'grid', type: 'grid',
schema: [ schema: [
@ -35,6 +42,7 @@ class AirQualityCard extends HTMLElement {
{ name: 'air_quality_entity', selector: { entity: { domain: 'sensor' } } }, { name: 'air_quality_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'recommendation_entity', selector: { entity: { domain: 'sensor' } } }, { name: 'recommendation_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'hours_to_show', selector: { number: { min: 1, max: 168, mode: 'box', unit_of_measurement: 'hours' } } }, { name: 'hours_to_show', selector: { number: { min: 1, max: 168, mode: 'box', unit_of_measurement: 'hours' } } },
{ name: 'temperature_unit', selector: { select: { options: [{ value: 'F', label: 'Fahrenheit (°F)' }, { value: 'C', label: 'Celsius (°C)' }], mode: 'dropdown' } } },
] ]
} }
], ],
@ -46,8 +54,11 @@ class AirQualityCard extends HTMLElement {
humidity_entity: 'Humidity Sensor (optional)', humidity_entity: 'Humidity Sensor (optional)',
temperature_entity: 'Temperature Sensor (optional)', temperature_entity: 'Temperature Sensor (optional)',
air_quality_entity: 'Air Quality Index (optional)', air_quality_entity: 'Air Quality Index (optional)',
hcho_entity: 'Formaldehyde (HCHO; CH2O) Sensor (optional)',
tvoc_entity: 'Volatile Organic Compounds (tVOC) Sensor (optional)',
recommendation_entity: 'Recommendation Sensor (optional)', recommendation_entity: 'Recommendation Sensor (optional)',
hours_to_show: 'Graph History' hours_to_show: 'Graph History',
temperature_unit: 'Temperature Unit'
}; };
return labels[schema.name] || schema.name; return labels[schema.name] || schema.name;
} }
@ -89,6 +100,7 @@ class AirQualityCard extends HTMLElement {
this._config = { this._config = {
name: 'Air Quality', name: 'Air Quality',
hours_to_show: 24, hours_to_show: 24,
temperature_unit: 'F',
...config ...config
}; };
this._rendered = false; this._rendered = false;
@ -109,6 +121,8 @@ class AirQualityCard extends HTMLElement {
let size = 3; // Base size for header and recommendation let size = 3; // Base size for header and recommendation
if (this._config.co2_entity) size += 1; if (this._config.co2_entity) size += 1;
if (this._config.pm25_entity) size += 1; if (this._config.pm25_entity) size += 1;
if (this._config.hcho_entity) size += 1;
if (this._config.tvoc_entity) size += 1;
if (this._config.humidity_entity) size += 1; if (this._config.humidity_entity) size += 1;
if (this._config.temperature_entity) size += 1; if (this._config.temperature_entity) size += 1;
return size; return size;
@ -132,6 +146,14 @@ class AirQualityCard extends HTMLElement {
promises.push(this._fetchHistory(this._config.pm25_entity, startTime, endTime)); promises.push(this._fetchHistory(this._config.pm25_entity, startTime, endTime));
keys.push('pm25'); keys.push('pm25');
} }
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('voc');
}
if (this._config.humidity_entity) { if (this._config.humidity_entity) {
promises.push(this._fetchHistory(this._config.humidity_entity, startTime, endTime)); promises.push(this._fetchHistory(this._config.humidity_entity, startTime, endTime));
keys.push('humidity'); keys.push('humidity');
@ -196,6 +218,22 @@ class AirQualityCard extends HTMLElement {
return '#f44336'; return '#f44336';
} }
_getHCHOColor(value) {
if (value < 20) return '#4caf50';
if (value < 50) return '#8bc34a';
if (value < 100) return '#ffc107';
if (value < 200) return '#ff9800';
return '#f44336';
}
_getTVOCColor(value) {
if (value < 100) return '#4caf50';
if (value < 300) return '#8bc34a';
if (value < 500) return '#ffc107';
if (value < 1000) return '#ff9800';
return '#f44336';
}
_getHumidityColor(value) { _getHumidityColor(value) {
if (value < 30) return '#ff9800'; if (value < 30) return '#ff9800';
if (value < 40) return '#8bc34a'; if (value < 40) return '#8bc34a';
@ -204,7 +242,22 @@ class AirQualityCard extends HTMLElement {
return '#ff9800'; return '#ff9800';
} }
_isCelsius() {
return this._config.temperature_unit === 'C';
}
_getTempUnit() {
return this._isCelsius() ? '°C' : '°F';
}
_getTempColor(value) { _getTempColor(value) {
if (this._isCelsius()) {
if (value < 18) return '#2196f3';
if (value < 20) return '#03a9f4';
if (value < 22) return '#4caf50';
if (value < 24) return '#ff9800';
return '#f44336';
}
if (value < 65) return '#2196f3'; if (value < 65) return '#2196f3';
if (value < 68) return '#03a9f4'; if (value < 68) return '#03a9f4';
if (value < 72) return '#4caf50'; if (value < 72) return '#4caf50';
@ -284,6 +337,8 @@ class AirQualityCard extends HTMLElement {
_initialRender() { _initialRender() {
const showCO2 = !!this._config.co2_entity; const showCO2 = !!this._config.co2_entity;
const showPM25 = !!this._config.pm25_entity; const showPM25 = !!this._config.pm25_entity;
const showHCHO = !!this._config.hcho_entity;
const showTVOC = !!this._config.tvoc_entity;
const showHumidity = !!this._config.humidity_entity; const showHumidity = !!this._config.humidity_entity;
const showTemp = !!this._config.temperature_entity; const showTemp = !!this._config.temperature_entity;
@ -546,6 +601,45 @@ class AirQualityCard extends HTMLElement {
<div class="graph-time-axis" id="pm25-time-axis"></div> <div class="graph-time-axis" id="pm25-time-axis"></div>
</div> </div>
` : ''} ` : ''}
${showHCHO ? `
<div class="graph-container" id="hcho-graph-container" data-entity="${this._config.hcho_entity}">
<div class="graph-header">
<span class="graph-label">HCHO / CH₂O</span>
<span class="graph-value" id="hcho-value">-- <span class="unit">ppm</span><span class="status" id="hcho-status"></span></span>
</div>
<div class="graph-wrapper">
<div class="graph" id="hcho-graph">
<svg id="hcho-svg" viewBox="0 0 300 50" preserveAspectRatio="none"></svg>
</div>
<div class="graph-cursor" id="hcho-cursor"></div>
<div class="graph-tooltip" id="hcho-tooltip">
<div class="graph-tooltip-value"></div>
<div class="graph-tooltip-time"></div>
</div>
</div>
<div class="graph-time-axis" id="hcho-time-axis"></div>
</div>
` : ''}
${showTVOC ? `
<div class="graph-container" id="tvoc-graph-container" data-entity="${this._config.tvoc_entity}">
<div class="graph-header">
<span class="graph-label">tVOC</span>
<span class="graph-value" id="tvoc-value">-- <span class="unit">μg/</span><span class="status" id="tvoc-status"></span></span>
</div>
<div class="graph-wrapper">
<div class="graph" id="tvoc-graph">
<svg id="tvoc-svg" viewBox="0 0 300 50" preserveAspectRatio="none"></svg>
</div>
<div class="graph-cursor" id="tvoc-cursor"></div>
<div class="graph-tooltip" id="tvoc-tooltip">
<div class="graph-tooltip-value"></div>
<div class="graph-tooltip-time"></div>
</div>
</div>
<div class="graph-time-axis" id="tvoc-time-axis"></div>
</div>
` : ''}
${showHumidity ? ` ${showHumidity ? `
<div class="graph-container" id="humidity-graph-container" data-entity="${this._config.humidity_entity}"> <div class="graph-container" id="humidity-graph-container" data-entity="${this._config.humidity_entity}">
@ -571,7 +665,7 @@ class AirQualityCard extends HTMLElement {
<div class="graph-container" id="temperature-graph-container" data-entity="${this._config.temperature_entity}"> <div class="graph-container" id="temperature-graph-container" data-entity="${this._config.temperature_entity}">
<div class="graph-header"> <div class="graph-header">
<span class="graph-label">Temperature</span> <span class="graph-label">Temperature</span>
<span class="graph-value" id="temperature-value">-- <span class="unit">°F</span><span class="status" id="temperature-status"></span></span> <span class="graph-value" id="temperature-value">-- <span class="unit">${this._getTempUnit()}</span><span class="status" id="temperature-status"></span></span>
</div> </div>
<div class="graph-wrapper"> <div class="graph-wrapper">
<div class="graph" id="temperature-graph"> <div class="graph" id="temperature-graph">
@ -597,6 +691,8 @@ class AirQualityCard extends HTMLElement {
const co2 = this._config.co2_entity ? this._getNumericState(this._config.co2_entity) : null; const co2 = this._config.co2_entity ? this._getNumericState(this._config.co2_entity) : null;
const pm25 = this._config.pm25_entity ? this._getNumericState(this._config.pm25_entity) : null; const pm25 = this._config.pm25_entity ? this._getNumericState(this._config.pm25_entity) : null;
const hcho = this._config.hcho_entity ? this._getNumericState(this._config.hcho_entity) : null;
const tvoc = this._config.tvoc_entity ? this._getNumericState(this._config.tvoc_entity) : null;
const humidity = this._config.humidity_entity ? this._getNumericState(this._config.humidity_entity) : null; const humidity = this._config.humidity_entity ? this._getNumericState(this._config.humidity_entity) : null;
const temp = this._config.temperature_entity ? this._getNumericState(this._config.temperature_entity) : null; const temp = this._config.temperature_entity ? this._getNumericState(this._config.temperature_entity) : null;
const recommendation = this._getRecommendation(); const recommendation = this._getRecommendation();
@ -682,6 +778,34 @@ class AirQualityCard extends HTMLElement {
} }
} }
// Update HCHO
if (hcho !== null) {
const hchoColor = this._getHCHOColor(hcho);
const hchoValueEl = this.shadowRoot.getElementById('hcho-value');
if (hchoValueEl) {
hchoValueEl.innerHTML = `${hcho.toFixed(1)} <span class="unit">ppb</span><span class="status" id="hcho-status"></span>`;
const statusEl = hchoValueEl.querySelector('.status');
statusEl.textContent = hcho < 20 ? 'Excellent' : hcho < 50 ? 'Good' : hcho < 100 ? 'Moderate' : hcho < 200 ? 'Elevated' : 'Poor';
statusEl.style.background = hchoColor + '22';
statusEl.style.color = hchoColor;
hchoValueEl.style.color = hchoColor;
}
}
// Update tVOC
if (tvoc !== null) {
const tvocColor = this._getTVOCColor(tvoc);
const tvocValueEl = this.shadowRoot.getElementById('tvoc-value');
if (tvocValueEl) {
tvocValueEl.innerHTML = `${tvoc.toFixed(1)} <span class="unit">ppb</span><span class="status" id="tvoc-status"></span>`;
const statusEl = tvocValueEl.querySelector('.status');
statusEl.textContent = tvoc < 100 ? 'Excellent' : tvoc < 300 ? 'Good' : tvoc < 500 ? 'Moderate' : tvoc < 1000 ? 'Elevated' : 'Poor';
statusEl.style.background = tvocColor + '22';
statusEl.style.color = tvocColor;
tvocValueEl.style.color = tvocColor;
}
}
// Update Humidity // Update Humidity
if (humidity !== null) { if (humidity !== null) {
const humidityColor = this._getHumidityColor(humidity); const humidityColor = this._getHumidityColor(humidity);
@ -704,15 +828,23 @@ class AirQualityCard extends HTMLElement {
// Update Temperature // Update Temperature
if (temp !== null) { if (temp !== null) {
const tempColor = this._getTempColor(temp); const tempColor = this._getTempColor(temp);
const tempUnit = this._getTempUnit();
const tempValueEl = this.shadowRoot.getElementById('temperature-value'); const tempValueEl = this.shadowRoot.getElementById('temperature-value');
if (tempValueEl) { if (tempValueEl) {
tempValueEl.innerHTML = `${Math.round(temp)} <span class="unit">°F</span><span class="status" id="temperature-status"></span>`; tempValueEl.innerHTML = `${Math.round(temp)} <span class="unit">${tempUnit}</span><span class="status" id="temperature-status"></span>`;
const statusEl = tempValueEl.querySelector('.status'); const statusEl = tempValueEl.querySelector('.status');
let tempStatus = 'Comfortable'; let tempStatus = 'Comfortable';
if (temp < 65) tempStatus = 'Cold'; if (this._isCelsius()) {
else if (temp < 68) tempStatus = 'Cool'; if (temp < 18) tempStatus = 'Cold';
else if (temp > 76) tempStatus = 'Hot'; else if (temp < 20) tempStatus = 'Cool';
else if (temp > 72) tempStatus = 'Warm'; else if (temp > 24) tempStatus = 'Hot';
else if (temp > 22) tempStatus = 'Warm';
} else {
if (temp < 65) tempStatus = 'Cold';
else if (temp < 68) tempStatus = 'Cool';
else if (temp > 76) tempStatus = 'Hot';
else if (temp > 72) tempStatus = 'Warm';
}
statusEl.textContent = tempStatus; statusEl.textContent = tempStatus;
statusEl.style.background = tempColor + '22'; statusEl.style.background = tempColor + '22';
statusEl.style.color = tempColor; statusEl.style.color = tempColor;
@ -730,11 +862,20 @@ class AirQualityCard extends HTMLElement {
if (this._config.pm25_entity && this._history.pm25.length) { if (this._config.pm25_entity && this._history.pm25.length) {
this._renderGraph('pm25', this._history.pm25, this._getPM25Color.bind(this), 0, 60, 'μg/m³'); this._renderGraph('pm25', this._history.pm25, this._getPM25Color.bind(this), 0, 60, 'μg/m³');
} }
if (this._config.hcho_entity && this._history.hcho.length) {
this._renderGraph('hcho', this._history.hcho, this._getHCHOColor.bind(this), 0, 60, 'ppb');
}
if (this._config.tvoc_entity && this._history.tvoc.length) {
this._renderGraph('tvoc', this._history.tvoc, this._getTVOCColor.bind(this), 0, 60, 'ppb');
}
if (this._config.humidity_entity && this._history.humidity.length) { if (this._config.humidity_entity && this._history.humidity.length) {
this._renderGraph('humidity', this._history.humidity, this._getHumidityColor.bind(this), 0, 100, '%'); this._renderGraph('humidity', this._history.humidity, this._getHumidityColor.bind(this), 0, 100, '%');
} }
if (this._config.temperature_entity && this._history.temperature.length) { if (this._config.temperature_entity && this._history.temperature.length) {
this._renderGraph('temperature', this._history.temperature, this._getTempColor.bind(this), 50, 90, '°F'); const tempUnit = this._getTempUnit();
const tempMin = this._isCelsius() ? 10 : 50;
const tempMax = this._isCelsius() ? 32 : 90;
this._renderGraph('temperature', this._history.temperature, this._getTempColor.bind(this), tempMin, tempMax, tempUnit);
} }
this._setupGraphInteractions(); this._setupGraphInteractions();
@ -915,7 +1056,7 @@ class AirQualityCard extends HTMLElement {
if (valueEl) { if (valueEl) {
let displayValue; let displayValue;
if (data.unit === 'ppm') displayValue = Math.round(closest.value); if (data.unit === 'ppm') displayValue = Math.round(closest.value);
else if (data.unit === '%' || data.unit === '°F') displayValue = Math.round(closest.value); else if (data.unit === '%' || data.unit === '°F' || data.unit === '°C') displayValue = Math.round(closest.value);
else displayValue = closest.value.toFixed(1); else displayValue = closest.value.toFixed(1);
valueEl.textContent = `${displayValue} ${data.unit}`; valueEl.textContent = `${displayValue} ${data.unit}`;
valueEl.style.color = closest.color; valueEl.style.color = closest.color;
@ -975,6 +1116,7 @@ if (LitElement && !customElements.get('air-quality-card-editor')) {
this._config = { this._config = {
name: 'Air Quality', name: 'Air Quality',
hours_to_show: 24, hours_to_show: 24,
temperature_unit: 'F',
...config ...config
}; };
} }
@ -984,11 +1126,14 @@ if (LitElement && !customElements.get('air-quality-card-editor')) {
name: 'Card Name', name: 'Card Name',
co2_entity: 'CO₂ Sensor', co2_entity: 'CO₂ Sensor',
pm25_entity: 'PM2.5 Sensor', pm25_entity: 'PM2.5 Sensor',
hcho_entity: 'HCHO Sensor (optional)',
tvoc_entity: 'tVOC Sensor (optional)',
humidity_entity: 'Humidity Sensor (optional)', humidity_entity: 'Humidity Sensor (optional)',
temperature_entity: 'Temperature Sensor (optional)', temperature_entity: 'Temperature Sensor (optional)',
air_quality_entity: 'Air Quality Index (optional)', air_quality_entity: 'Air Quality Index (optional)',
recommendation_entity: 'Recommendation Sensor (optional)', recommendation_entity: 'Recommendation Sensor (optional)',
hours_to_show: 'Graph History (hours)' hours_to_show: 'Graph History (hours)',
temperature_unit: 'Temperature Unit'
}; };
return labels[schema.name] || schema.name; return labels[schema.name] || schema.name;
} }
@ -998,11 +1143,14 @@ if (LitElement && !customElements.get('air-quality-card-editor')) {
{ name: 'name', selector: { text: {} } }, { name: 'name', selector: { text: {} } },
{ name: 'co2_entity', selector: { entity: { domain: 'sensor' } } }, { name: 'co2_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'pm25_entity', selector: { entity: { domain: 'sensor' } } }, { name: 'pm25_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'hcho_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'tvoc_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'humidity_entity', selector: { entity: { domain: 'sensor' } } }, { name: 'humidity_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'temperature_entity', selector: { entity: { domain: 'sensor' } } }, { name: 'temperature_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'air_quality_entity', selector: { entity: { domain: 'sensor' } } }, { name: 'air_quality_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'recommendation_entity', selector: { entity: { domain: 'sensor' } } }, { name: 'recommendation_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'hours_to_show', selector: { number: { min: 1, max: 168, mode: 'box' } } } { name: 'hours_to_show', selector: { number: { min: 1, max: 168, mode: 'box' } } },
{ name: 'temperature_unit', selector: { select: { options: [{ value: 'F', label: 'Fahrenheit (°F)' }, { value: 'C', label: 'Celsius (°C)' }], mode: 'dropdown' } } }
]; ];
} }