From ef2922a1f6ff92cf02178aea372304bb7730fa6c Mon Sep 17 00:00:00 2001 From: KadenThomp36 Date: Wed, 11 Feb 2026 10:42:34 -0500 Subject: [PATCH] Initial release v2.1.0 Air Quality Card for Home Assistant with WHO-based thresholds, gradient graphs, and visual configuration editor. --- LICENSE | 21 + README.md | 168 +++++++ air-quality-card.js | 1044 +++++++++++++++++++++++++++++++++++++++++++ hacs.json | 6 + 4 files changed, 1239 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 air-quality-card.js create mode 100644 hacs.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f246b8 --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ +# Air Quality Card + +A custom Home Assistant Lovelace card for monitoring indoor air quality with beautiful gradient graphs and WHO-based health thresholds. + +![Air Quality Card Preview](https://raw.githubusercontent.com/KadenThomp36/air-quality-card/main/images/preview.png) + +## Features + +- **Real-time monitoring** of CO2, PM2.5, humidity, and temperature +- **Gradient-colored graphs** that change color based on air quality levels +- **Interactive hover/touch** to see historical values at any point +- **Health-based thresholds** following WHO 2021 guidelines and ASHRAE standards +- **Actionable recommendations** like "Open Window" or "Run Air Purifier" +- **Tap to expand** - click any graph to open the full Home Assistant history view +- **Visual configuration editor** - no YAML required + +## Installation + +### HACS (Recommended) + +1. Open HACS in Home Assistant +2. Click on "Frontend" +3. Click the three dots in the top right and select "Custom repositories" +4. Add the repository URL: `https://github.com/KadenThomp36/air-quality-card` +5. Select "Lovelace" as the category +6. Click "Add" +7. Search for "Air Quality Card" and install it +8. Refresh your browser + +### Manual Installation + +1. Download `air-quality-card.js` from the latest release +2. Copy it to `/config/www/air-quality-card/air-quality-card.js` +3. Add the resource in Home Assistant: + - Go to Settings → Dashboards → Resources + - Add `/local/air-quality-card/air-quality-card.js` as a JavaScript Module + +## Configuration + +### Using the Visual Editor + +1. Add a new card to your dashboard +2. Search for "Air Quality Card" +3. Configure the entities using the visual editor + +### YAML Configuration + +```yaml +type: custom:air-quality-card +name: Office Air Quality +co2_entity: sensor.air_quality_co2 +pm25_entity: sensor.air_quality_pm25 +humidity_entity: sensor.air_quality_humidity +temperature_entity: sensor.air_quality_temperature +air_quality_entity: sensor.air_quality_index +recommendation_entity: sensor.air_quality_recommendation +hours_to_show: 24 +``` + +### Configuration Options + +| Option | Type | Required | Default | Description | +|--------|------|----------|---------|-------------| +| `name` | string | No | "Air Quality" | Card title | +| `co2_entity` | string | Yes | - | CO2 sensor entity ID | +| `pm25_entity` | string | Yes | - | PM2.5 sensor entity ID | +| `humidity_entity` | string | No | - | Humidity sensor entity ID | +| `temperature_entity` | string | No | - | Temperature sensor entity ID | +| `air_quality_entity` | string | No | - | Overall air quality index entity | +| `recommendation_entity` | string | No | - | Recommendation template sensor | +| `hours_to_show` | number | No | 24 | Hours of history to display (1-168) | + +## Recommendation Sensor + +For the best experience, create a template sensor that provides recommendations. Add this to your `configuration.yaml`: + +```yaml +template: + - sensor: + - name: "Air Quality Recommendation" + unique_id: air_quality_recommendation + state: > + {% set co2 = states('sensor.YOUR_CO2_SENSOR') | float(0) %} + {% set pm25 = states('sensor.YOUR_PM25_SENSOR') | float(0) %} + {% set humidity = states('sensor.YOUR_HUMIDITY_SENSOR') | float(0) %} + {% if co2 > 1500 %} + Ventilate Now + {% elif pm25 > 35 %} + Run Air Purifier + {% elif pm25 > 25 and co2 > 1000 %} + Air Purifier + Ventilate + {% elif pm25 > 25 %} + Run Air Purifier + {% elif co2 > 1000 %} + Open Window + {% elif humidity < 30 %} + Too Dry + {% elif humidity > 60 %} + Too Humid + {% elif co2 > 800 or pm25 > 15 %} + Consider Ventilating + {% else %} + All Good + {% endif %} +``` + +## Health Thresholds + +### CO2 (Carbon Dioxide) +| Level | Range | Color | Meaning | +|-------|-------|-------|---------| +| Excellent | < 600 ppm | Green | Fresh outdoor air levels | +| Good | 600-800 ppm | Light Green | Well-ventilated space | +| Moderate | 800-1000 ppm | Yellow | Acceptable, consider ventilation | +| Elevated | 1000-1500 ppm | Orange | May affect concentration | +| Poor | > 1500 ppm | Red | Ventilation needed | + +### PM2.5 (Fine Particulate Matter) +Based on WHO 2021 Air Quality Guidelines: +| Level | Range | Color | Meaning | +|-------|-------|-------|---------| +| Excellent | < 5 µg/m³ | Green | WHO annual guideline | +| Good | 5-15 µg/m³ | Light Green | WHO 24-hour guideline | +| Moderate | 15-25 µg/m³ | Yellow | Slightly elevated | +| Elevated | 25-35 µg/m³ | Orange | Consider air purifier | +| Poor | > 35 µg/m³ | Red | Air purifier recommended | + +### Humidity +| Level | Range | Color | Meaning | +|-------|-------|-------|---------| +| Too Dry | < 30% | Orange | Use humidifier | +| Dry | 30-40% | Light Green | Acceptable | +| Comfortable | 40-50% | Green | Ideal range | +| Humid | 50-60% | Light Green | Acceptable | +| Too Humid | > 60% | Orange | Improve ventilation | + +## Supported Devices + +This card works with any air quality sensor that provides entities for CO2 and PM2.5. Tested with: + +- IKEA VINDSTYRKA / ALPSTUGA (via Matter) +- Aqara TVOC Air Quality Monitor +- Xiaomi Air Quality Monitor +- SenseAir S8 +- Any ESPHome-based air quality sensor + +## Development + +```bash +# Clone the repository +git clone https://github.com/KadenThomp36/air-quality-card.git + +# The card is vanilla JavaScript with no build step required +# Simply edit air-quality-card.js and test in Home Assistant +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +MIT License - see [LICENSE](LICENSE) for details. + +## Credits + +- Thresholds based on [WHO 2021 Air Quality Guidelines](https://www.who.int/publications/i/item/9789240034228) +- CO2 recommendations based on [ASHRAE Standard 62.1](https://www.ashrae.org/technical-resources/bookstore/standards-62-1-62-2) diff --git a/air-quality-card.js b/air-quality-card.js new file mode 100644 index 0000000..1492551 --- /dev/null +++ b/air-quality-card.js @@ -0,0 +1,1044 @@ +/** + * Air Quality Card v2.1.0 + * 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.1.0'; + +class AirQualityCard extends HTMLElement { + // Visual editor using getConfigForm (preferred modern approach) + static getConfigForm() { + return { + schema: [ + { name: 'name', selector: { text: {} } }, + { + type: 'grid', + schema: [ + { name: 'co2_entity', selector: { entity: { domain: 'sensor' } } }, + { name: 'pm25_entity', selector: { entity: { domain: 'sensor' } } }, + ] + }, + { + type: 'grid', + schema: [ + { name: 'humidity_entity', selector: { entity: { domain: 'sensor' } } }, + { name: 'temperature_entity', selector: { entity: { domain: 'sensor' } } }, + ] + }, + { + type: 'expandable', + title: 'Advanced', + schema: [ + { name: 'air_quality_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' } } }, + ] + } + ], + computeLabel: (schema) => { + const labels = { + name: 'Card Name', + co2_entity: 'CO₂ Sensor', + pm25_entity: 'PM2.5 Sensor', + humidity_entity: 'Humidity Sensor (optional)', + temperature_entity: 'Temperature Sensor (optional)', + air_quality_entity: 'Air Quality Index (optional)', + recommendation_entity: 'Recommendation Sensor (optional)', + hours_to_show: 'Graph History' + }; + return labels[schema.name] || schema.name; + } + }; + } + + // Fallback for older HA versions - use getConfigElement + 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: [], humidity: [], temperature: [] }; + this._historyLoaded = false; + this._graphData = {}; + this._isDragging = false; + } + + setConfig(config) { + if (!config) throw new Error('Invalid configuration'); + + // Validate required entities + if (!config.co2_entity && !config.pm25_entity) { + throw new Error('Please configure at least a CO₂ or PM2.5 sensor entity'); + } + + this._config = { + name: 'Air Quality', + hours_to_show: 24, + ...config + }; + this._rendered = false; + this._historyLoaded = false; + } + + set hass(hass) { + this._hass = hass; + if (!this._rendered) { + this._initialRender(); + this._rendered = true; + this._loadHistory(); + } + this._updateStates(); + } + + getCardSize() { + let size = 3; // Base size for header and recommendation + if (this._config.co2_entity) size += 1; + if (this._config.pm25_entity) size += 1; + if (this._config.humidity_entity) size += 1; + if (this._config.temperature_entity) size += 1; + return size; + } + + 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)); + + try { + const promises = []; + const keys = []; + + 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.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'); + } + + 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; + } + + _getCO2Color(value) { + if (value < 600) return '#4caf50'; + if (value < 800) return '#8bc34a'; + if (value < 1000) return '#ffc107'; + if (value < 1500) return '#ff9800'; + return '#f44336'; + } + + _getPM25Color(value) { + if (value < 5) return '#4caf50'; + if (value < 15) return '#8bc34a'; + if (value < 25) return '#ffc107'; + if (value < 35) return '#ff9800'; + return '#f44336'; + } + + _getHumidityColor(value) { + if (value < 30) return '#ff9800'; + if (value < 40) return '#8bc34a'; + if (value < 50) return '#4caf50'; + if (value < 60) return '#8bc34a'; + return '#ff9800'; + } + + _getTempColor(value) { + if (value < 65) return '#2196f3'; + if (value < 68) return '#03a9f4'; + if (value < 72) return '#4caf50'; + if (value < 76) return '#ff9800'; + return '#f44336'; + } + + _getOverallStatus() { + 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; + + // If air_quality_entity is configured, use it + if (this._config.air_quality_entity) { + const quality = this._getState(this._config.air_quality_entity); + return { status: quality.replace('_', ' '), color: this._getQualityColor(quality) }; + } + + // Otherwise calculate from CO2 and PM2.5 + if (co2 > 1500 || pm25 > 35) return { status: 'Poor', color: '#f44336' }; + if (co2 > 1000 || pm25 > 25) return { status: 'Fair', color: '#ff9800' }; + if (co2 > 800 || pm25 > 15) return { status: 'Moderate', color: '#ffc107' }; + if (co2 > 600 || pm25 > 5) return { status: 'Good', color: '#8bc34a' }; + return { 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'; + } + + _getRecommendation() { + // If recommendation_entity is configured, use it + if (this._config.recommendation_entity) { + const rec = this._getState(this._config.recommendation_entity); + return rec !== 'unknown' ? rec : null; + } + + // Otherwise calculate from sensor values + 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 humidity = this._config.humidity_entity ? this._getNumericState(this._config.humidity_entity) : 45; + + if (co2 > 1500) return 'Ventilate Now'; + if (pm25 > 35) return 'Run Air Purifier'; + if (pm25 > 25 && co2 > 1000) return 'Air Purifier + Ventilate'; + if (pm25 > 25) return 'Run Air Purifier'; + if (co2 > 1000) return 'Open Window'; + if (humidity < 30) return 'Too Dry'; + if (humidity > 60) return 'Too Humid'; + if (co2 > 800 || pm25 > 15) return 'Consider Ventilating'; + return 'All Good'; + } + + _getRecommendationIcon(rec) { + const icons = { + 'All Good': 'mdi:check-circle', + 'Consider Ventilating': 'mdi:information', + 'Open Window': 'mdi:window-open-variant', + 'Run Air Purifier': 'mdi:air-purifier', + 'Air Purifier + Ventilate': 'mdi:alert', + 'Ventilate Now': 'mdi:alert-circle', + 'Too Dry': 'mdi:water-percent', + 'Too Humid': 'mdi:water' + }; + return icons[rec] || 'mdi:air-filter'; + } + + _initialRender() { + const showCO2 = !!this._config.co2_entity; + const showPM25 = !!this._config.pm25_entity; + const showHumidity = !!this._config.humidity_entity; + const showTemp = !!this._config.temperature_entity; + + this.shadowRoot.innerHTML = ` + + + +
+
+ ${this._config.name} +
+ + Good +
+
+ +
+ +
+
All Good
+
Air quality is within healthy limits
+
+
+ +
+ ${showCO2 ? ` +
+
+ CO₂ + -- ppm +
+
+
+ +
+
+
+
+
+
+
+
+
+ ` : ''} + + ${showPM25 ? ` +
+
+ PM2.5 + -- μg/m³ +
+
+
+ +
+
+
+
+
+
+
+
+
+ ` : ''} + + ${showHumidity ? ` +
+
+ Humidity + -- % +
+
+
+ +
+
+
+
+
+
+
+
+
+ ` : ''} + + ${showTemp ? ` +
+
+ Temperature + -- °F +
+
+
+ +
+
+
+
+
+
+
+
+
+ ` : ''} +
+
+
+ `; + } + + _updateStates() { + if (!this._hass || !this._rendered) return; + + 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 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 recommendation = this._getRecommendation(); + const overall = this._getOverallStatus(); + + // Update status badge + const statusBadge = this.shadowRoot.getElementById('status-badge'); + const statusText = this.shadowRoot.getElementById('status-text'); + const statusIcon = this.shadowRoot.getElementById('status-icon'); + + if (statusBadge) { + statusBadge.style.background = overall.color + '22'; + statusBadge.style.color = overall.color; + statusText.textContent = overall.status; + statusIcon.style.color = overall.color; + } + + // Update recommendation + const recIcon = this.shadowRoot.getElementById('rec-icon'); + const recTitle = this.shadowRoot.getElementById('rec-title'); + const recSubtitle = this.shadowRoot.getElementById('rec-subtitle'); + const recContainer = this.shadowRoot.getElementById('recommendation'); + + if (recIcon && recommendation) { + recIcon.setAttribute('icon', this._getRecommendationIcon(recommendation)); + recTitle.textContent = recommendation; + + let subtitle = ''; + if (recommendation === 'All Good') { + subtitle = 'Air quality is within healthy limits'; + } else if (recommendation === 'Run Air Purifier' && pm25 !== null) { + subtitle = `PM2.5 at ${pm25.toFixed(0)} μg/m³ - filter the air`; + } else if (recommendation === 'Open Window' && co2 !== null) { + subtitle = `CO₂ at ${Math.round(co2)} ppm - fresh air needed`; + } else if (recommendation === 'Air Purifier + Ventilate' && co2 !== null && pm25 !== null) { + subtitle = `CO₂: ${Math.round(co2)} ppm, PM2.5: ${pm25.toFixed(0)} μg/m³`; + } else if (recommendation === 'Ventilate Now' && co2 !== null) { + subtitle = `CO₂ at ${Math.round(co2)} ppm - may affect focus`; + } else if (recommendation === 'Too Dry' && humidity !== null) { + subtitle = `Humidity at ${Math.round(humidity)}% - consider humidifier`; + } else if (recommendation === 'Too Humid' && humidity !== null) { + subtitle = `Humidity at ${Math.round(humidity)}% - ventilate`; + } else if (recommendation === 'Consider Ventilating') { + if (co2 !== null && co2 > 800) subtitle = `CO₂ at ${Math.round(co2)} ppm`; + else if (pm25 !== null && pm25 > 15) subtitle = `PM2.5 at ${pm25.toFixed(0)} μg/m³`; + else subtitle = 'Slightly elevated levels'; + } + recSubtitle.textContent = subtitle; + + const isGood = recommendation === 'All Good'; + const isPoor = ['Run Air Purifier', 'Open Window', 'Ventilate Now', 'Air Purifier + Ventilate'].includes(recommendation); + recIcon.style.color = isGood ? 'var(--aq-excellent)' : (isPoor ? 'var(--aq-poor)' : 'var(--aq-moderate)'); + recContainer.style.background = isGood ? + 'rgba(76, 175, 80, 0.1)' : (isPoor ? 'rgba(255, 152, 0, 0.15)' : 'rgba(255, 193, 7, 0.1)'); + } + + // Update CO2 + if (co2 !== null) { + const co2Color = this._getCO2Color(co2); + const co2ValueEl = this.shadowRoot.getElementById('co2-value'); + const co2StatusEl = this.shadowRoot.getElementById('co2-status'); + if (co2ValueEl) { + co2ValueEl.innerHTML = `${Math.round(co2)} ppm`; + const statusEl = co2ValueEl.querySelector('.status'); + statusEl.textContent = co2 < 800 ? 'Excellent' : co2 < 1000 ? 'Good' : co2 < 1500 ? 'Elevated' : 'Poor'; + statusEl.style.background = co2Color + '22'; + statusEl.style.color = co2Color; + co2ValueEl.style.color = co2Color; + } + } + + // Update PM2.5 + if (pm25 !== null) { + const pm25Color = this._getPM25Color(pm25); + const pm25ValueEl = this.shadowRoot.getElementById('pm25-value'); + if (pm25ValueEl) { + pm25ValueEl.innerHTML = `${pm25.toFixed(1)} μg/m³`; + const statusEl = pm25ValueEl.querySelector('.status'); + statusEl.textContent = pm25 < 5 ? 'Excellent' : pm25 < 15 ? 'Good' : pm25 < 25 ? 'Moderate' : pm25 < 35 ? 'Elevated' : 'Poor'; + statusEl.style.background = pm25Color + '22'; + statusEl.style.color = pm25Color; + pm25ValueEl.style.color = pm25Color; + } + } + + // Update Humidity + if (humidity !== null) { + const humidityColor = this._getHumidityColor(humidity); + const humidityValueEl = this.shadowRoot.getElementById('humidity-value'); + if (humidityValueEl) { + humidityValueEl.innerHTML = `${Math.round(humidity)} %`; + const statusEl = humidityValueEl.querySelector('.status'); + let humidityStatus = 'Comfortable'; + if (humidity < 30) humidityStatus = 'Too Dry'; + else if (humidity < 40) humidityStatus = 'Dry'; + else if (humidity > 60) humidityStatus = 'Too Humid'; + else if (humidity > 50) humidityStatus = 'Humid'; + statusEl.textContent = humidityStatus; + statusEl.style.background = humidityColor + '22'; + statusEl.style.color = humidityColor; + humidityValueEl.style.color = humidityColor; + } + } + + // Update Temperature + if (temp !== null) { + const tempColor = this._getTempColor(temp); + const tempValueEl = this.shadowRoot.getElementById('temperature-value'); + if (tempValueEl) { + tempValueEl.innerHTML = `${Math.round(temp)} °F`; + const statusEl = tempValueEl.querySelector('.status'); + let tempStatus = 'Comfortable'; + 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.style.background = tempColor + '22'; + statusEl.style.color = tempColor; + tempValueEl.style.color = tempColor; + } + } + } + + _renderGraphs() { + this._graphData = {}; + + if (this._config.co2_entity && this._history.co2.length) { + this._renderGraph('co2', this._history.co2, this._getCO2Color.bind(this), 400, 2000, 'ppm'); + } + if (this._config.pm25_entity && this._history.pm25.length) { + this._renderGraph('pm25', this._history.pm25, this._getPM25Color.bind(this), 0, 60, 'μg/m³'); + } + if (this._config.humidity_entity && this._history.humidity.length) { + this._renderGraph('humidity', this._history.humidity, this._getHumidityColor.bind(this), 0, 100, '%'); + } + if (this._config.temperature_entity && this._history.temperature.length) { + this._renderGraph('temperature', this._history.temperature, this._getTempColor.bind(this), 50, 90, '°F'); + } + + this._setupGraphInteractions(); + } + + _renderGraph(graphId, data, colorFn, minVal, maxVal, unit) { + const svg = this.shadowRoot.getElementById(`${graphId}-svg`); + const timeAxis = this.shadowRoot.getElementById(`${graphId}-time-axis`); + if (!svg || !data.length) return; + + const width = 300; + const height = 50; + const padding = 2; + + const values = data.map(d => d.value); + const dataMin = Math.min(...values, minVal); + const dataMax = Math.max(...values, maxVal); + const range = dataMax - dataMin || 1; + + const points = data.map((d, i) => { + const x = padding + (i / (data.length - 1)) * (width - 2 * padding); + const y = height - padding - ((d.value - dataMin) / range) * (height - 2 * padding); + return { x, y, value: d.value, time: d.time, color: colorFn(d.value) }; + }); + + this._graphData[graphId] = { points, unit, colorFn }; + + if (points.length < 2) return; + + const gradientId = `gradient-${graphId}-${Date.now()}`; + let gradientStops = ''; + for (let i = 0; i < points.length; i++) { + const pct = (i / (points.length - 1)) * 100; + gradientStops += ``; + } + + let linePath = `M ${points[0].x} ${points[0].y}`; + for (let i = 1; i < points.length; i++) { + linePath += ` L ${points[i].x} ${points[i].y}`; + } + + const areaPath = linePath + ` L ${points[points.length - 1].x} ${height} L ${points[0].x} ${height} Z`; + const fillGradientId = `fill-${graphId}-${Date.now()}`; + + svg.innerHTML = ` + + + ${gradientStops} + + + + + + + + + + + + `; + + if (timeAxis && points.length > 0) { + const startTime = new Date(points[0].time); + const endTime = new Date(points[points.length - 1].time); + const midTime = new Date((startTime.getTime() + endTime.getTime()) / 2); + + const formatTime = (d) => d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + timeAxis.innerHTML = ` + ${formatTime(startTime)} + ${formatTime(midTime)} + ${formatTime(endTime)} + `; + } + } + + _setupGraphInteractions() { + const graphIds = ['co2', 'pm25', 'humidity', 'temperature'].filter(id => { + return this._config[`${id === 'pm25' ? 'pm25' : id}_entity`]; + }); + + graphIds.forEach(graphId => { + const container = this.shadowRoot.getElementById(`${graphId}-graph-container`); + const graphEl = this.shadowRoot.getElementById(`${graphId}-graph`); + const cursor = this.shadowRoot.getElementById(`${graphId}-cursor`); + const tooltip = this.shadowRoot.getElementById(`${graphId}-tooltip`); + + if (!container || !graphEl || !cursor || !tooltip) return; + + const entityId = container.dataset.entity; + + container.addEventListener('click', (e) => { + if (this._isDragging) { + this._isDragging = false; + return; + } + const event = new CustomEvent('hass-more-info', { + bubbles: true, + composed: true, + detail: { entityId } + }); + this.dispatchEvent(event); + }); + + graphEl.addEventListener('mouseenter', () => this._showCursor(graphId)); + graphEl.addEventListener('mouseleave', () => this._hideCursor(graphId)); + graphEl.addEventListener('mousemove', (e) => this._updateCursor(graphId, e)); + + let touchTimeout; + graphEl.addEventListener('touchstart', (e) => { + touchTimeout = setTimeout(() => { + this._isDragging = true; + this._showCursor(graphId); + this._updateCursor(graphId, e.touches[0]); + }, 200); + }, { passive: true }); + + graphEl.addEventListener('touchmove', (e) => { + if (this._isDragging) { + e.preventDefault(); + this._updateCursor(graphId, e.touches[0]); + } + }, { passive: false }); + + graphEl.addEventListener('touchend', () => { + clearTimeout(touchTimeout); + if (this._isDragging) { + setTimeout(() => this._hideCursor(graphId), 1000); + } + }); + }); + } + + _showCursor(graphId) { + const cursor = this.shadowRoot.getElementById(`${graphId}-cursor`); + const tooltip = this.shadowRoot.getElementById(`${graphId}-tooltip`); + if (cursor) cursor.style.display = 'block'; + if (tooltip) tooltip.style.display = 'block'; + } + + _hideCursor(graphId) { + const cursor = this.shadowRoot.getElementById(`${graphId}-cursor`); + const tooltip = this.shadowRoot.getElementById(`${graphId}-tooltip`); + if (cursor) cursor.style.display = 'none'; + if (tooltip) tooltip.style.display = 'none'; + } + + _updateCursor(graphId, event) { + const graphEl = this.shadowRoot.getElementById(`${graphId}-graph`); + const cursor = this.shadowRoot.getElementById(`${graphId}-cursor`); + const tooltip = this.shadowRoot.getElementById(`${graphId}-tooltip`); + const data = this._graphData[graphId]; + + if (!graphEl || !cursor || !tooltip || !data || !data.points.length) return; + + const rect = graphEl.getBoundingClientRect(); + const x = event.clientX - rect.left; + const pct = Math.max(0, Math.min(1, x / rect.width)); + + const targetX = pct * 300; + let closest = data.points[0]; + let minDist = Math.abs(closest.x - targetX); + + for (const point of data.points) { + const dist = Math.abs(point.x - targetX); + if (dist < minDist) { + minDist = dist; + closest = point; + } + } + + cursor.style.left = `${pct * 100}%`; + cursor.style.background = closest.color; + cursor.style.setProperty('--cursor-color', closest.color); + + const valueEl = tooltip.querySelector('.graph-tooltip-value'); + const timeEl = tooltip.querySelector('.graph-tooltip-time'); + + if (valueEl) { + let displayValue; + if (data.unit === 'ppm') displayValue = Math.round(closest.value); + else if (data.unit === '%' || data.unit === '°F') displayValue = Math.round(closest.value); + else displayValue = closest.value.toFixed(1); + valueEl.textContent = `${displayValue} ${data.unit}`; + valueEl.style.color = closest.color; + } + + if (timeEl && closest.time) { + const time = new Date(closest.time); + timeEl.textContent = time.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + } + + let tooltipX = pct * 100; + if (tooltipX < 12) tooltipX = 12; + if (tooltipX > 88) tooltipX = 88; + tooltip.style.left = `${tooltipX}%`; + } +} + +// Register the card +customElements.define('air-quality-card', AirQualityCard); + +window.customCards = window.customCards || []; +window.customCards.push({ + type: 'air-quality-card', + name: 'Air Quality Card', + description: 'A custom card for air quality monitoring with WHO-based thresholds and gradient graphs', + preview: true, + documentationURL: 'https://github.com/KadenThomp36/air-quality-card' +}); + +console.info( + `%c AIR-QUALITY-CARD %c v${CARD_VERSION} `, + 'color: white; background: #4caf50; font-weight: bold;', + 'color: #4caf50; background: white; font-weight: bold;' +); + +// ============================================ +// FALLBACK VISUAL CONFIGURATION EDITOR +// For older Home Assistant versions that don't support getConfigForm +// ============================================ + +const LitElement = Object.getPrototypeOf( + customElements.get("hui-masonry-view") || customElements.get("hui-view") +); +const html = LitElement?.prototype?.html; +const css = LitElement?.prototype?.css; + +if (LitElement && !customElements.get('air-quality-card-editor')) { + class AirQualityCardEditor extends LitElement { + static get properties() { + return { + hass: { type: Object }, + _config: { type: Object } + }; + } + + setConfig(config) { + this._config = { + name: 'Air Quality', + hours_to_show: 24, + ...config + }; + } + + _computeLabel(schema) { + const labels = { + name: 'Card Name', + co2_entity: 'CO₂ Sensor', + pm25_entity: 'PM2.5 Sensor', + humidity_entity: 'Humidity Sensor (optional)', + temperature_entity: 'Temperature Sensor (optional)', + air_quality_entity: 'Air Quality Index (optional)', + recommendation_entity: 'Recommendation Sensor (optional)', + hours_to_show: 'Graph History (hours)' + }; + return labels[schema.name] || schema.name; + } + + _schema() { + return [ + { name: 'name', selector: { text: {} } }, + { name: 'co2_entity', selector: { entity: { domain: 'sensor' } } }, + { name: 'pm25_entity', selector: { entity: { domain: 'sensor' } } }, + { name: 'humidity_entity', selector: { entity: { domain: 'sensor' } } }, + { name: 'temperature_entity', selector: { entity: { domain: 'sensor' } } }, + { name: 'air_quality_entity', selector: { entity: { domain: 'sensor' } } }, + { name: 'recommendation_entity', selector: { entity: { domain: 'sensor' } } }, + { name: 'hours_to_show', selector: { number: { min: 1, max: 168, mode: 'box' } } } + ]; + } + + render() { + if (!this._config) return html``; + + return html` +
+ +
+ `; + } + + _valueChanged(ev) { + const newConfig = { type: 'custom:air-quality-card', ...ev.detail.value }; + this.dispatchEvent(new CustomEvent('config-changed', { + detail: { config: newConfig }, + bubbles: true, + composed: true + })); + } + + static get styles() { + return css` + .card-config { + padding: 16px; + } + `; + } + } + + customElements.define('air-quality-card-editor', AirQualityCardEditor); +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..942271e --- /dev/null +++ b/hacs.json @@ -0,0 +1,6 @@ +{ + "name": "Air Quality Card", + "render_readme": true, + "filename": "air-quality-card.js", + "homeassistant": "2024.1.0" +}