UNPKG

@homebridge-plugins/homebridge-air

Version:

The AirNow plugin allows you to monitor the current AirQuality for your Zip Code from HomeKit and Siri.

514 lines 32.4 kB
import { interval } from 'rxjs'; import { skipWhile } from 'rxjs/operators'; import striptags from 'striptags'; import { Agent, request } from 'undici'; import { AirNowUrl, AqicnUrl, HomeKitAQI, REQUEST_RATE_LIMIT_CONFIG, REQUEST_TIMEOUT_CONFIG, resolveAqicnLocationSegment, } from '../settings.js'; import { deviceBase } from './device.js'; const defaultApiAgent = new Agent({ connect: { timeout: REQUEST_TIMEOUT_CONFIG.DEFAULT_TIMEOUT, autoSelectFamily: true, autoSelectFamilyAttemptTimeout: REQUEST_TIMEOUT_CONFIG.AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT, }, }); const noFamilyAutoSelectAgent = new Agent({ connect: { timeout: REQUEST_TIMEOUT_CONFIG.DEFAULT_TIMEOUT, autoSelectFamily: false, }, }); /** * Platform Accessory * An instance of this class is created for each accessory your platform registers * Each accessory may expose multiple services of different service types. */ export class AirQualitySensor extends deviceBase { platform; // Service AirQualitySensor; // Track which pollutants have valid data to prevent unnecessary characteristic updates availablePollutants = new Set(); // Updates SensorUpdateInProgress; deviceStatus; // Caching to follow AirNow best practices - observations update hourly // Cache for 10 minutes minimum (AirNow updates between 10-30 min past the hour) lastRequestTime = 0; lastResponseData = null; cacheMaxAge = REQUEST_RATE_LIMIT_CONFIG.CACHE_MAX_AGE; apiCallCount = 0; apiCallResetTime = Date.now() + REQUEST_RATE_LIMIT_CONFIG.CALL_WINDOW_MS; constructor(platform, accessory, device) { super(platform, accessory, device); this.platform = platform; // AirQuality Sensor Service this.debugLog('Configure AirQuality Sensor Service'); accessory.context.AirQualitySensor = accessory.context.AirQualitySensor ?? {}; this.AirQualitySensor = { Name: this.accessory.displayName, Service: this.accessory.getService(this.hap.Service.AirQualitySensor) ?? this.accessory.addService(this.hap.Service.AirQualitySensor), AirQuality: accessory.context.AirQuality ?? this.hap.Characteristic.AirQuality.EXCELLENT, StatusFault: accessory.context.StatusFault ?? this.hap.Characteristic.StatusFault.NO_FAULT, OzoneDensity: accessory.context.OzoneDensity ?? 0, NitrogenDioxideDensity: accessory.context.NitrogenDioxideDensity ?? 0, SulphurDioxideDensity: accessory.context.SulphurDioxideDensity ?? 0, PM2_5Density: accessory.context.PM2_5Density ?? 0, PM10Density: accessory.context.PM10Density ?? 0, CarbonMonoxideLevel: accessory.context.CarbonMonoxideLevel ?? 0, }; accessory.context.AirQualitySensor = this.AirQualitySensor; // Add AirQuality Sensor Service's Characteristics this.AirQualitySensor.Service.setCharacteristic(this.hap.Characteristic.Name, this.AirQualitySensor.Name); // this is subject we use to track when we need to POST changes to the Air API this.SensorUpdateInProgress = false; // Retrieve initial values and updateHomekit this.refreshStatus(); // Start an update interval interval(this.deviceRefreshRate * 1000) .pipe(skipWhile(() => this.SensorUpdateInProgress)) .subscribe(async () => { await this.refreshStatus(); }); } /** * Parse the device status from the Air api */ async parseStatus() { try { const provider = this.device.provider; const status = provider === 'airnow' ? this.deviceStatus[0] : this.deviceStatus; // Clear previous pollutant availability tracking at the start this.availablePollutants.clear(); if (provider === 'airnow' && !status) { this.errorLog('AirNow air quality Configuration Error - Invalid ZipCode for %s.', provider); this.AirQualitySensor.StatusFault = this.hap.Characteristic.StatusFault.GENERAL_FAULT; } else if (provider === 'airnow' && typeof status.AQI === 'undefined') { this.errorLog('AirNow air quality Observation Error - %s for %s.', striptags(JSON.stringify(this.deviceStatus)), provider); this.AirQualitySensor.StatusFault = this.hap.Characteristic.StatusFault.GENERAL_FAULT; } else if (provider === 'aqicn' && (!this.deviceStatus || typeof this.deviceStatus.aqi === 'undefined')) { this.errorLog('AQICN air quality Data Error - Invalid response structure or missing AQI data for %s.', provider); await this.debugLog('AQICN response structure: %s', JSON.stringify(this.deviceStatus)); this.AirQualitySensor.StatusFault = this.hap.Characteristic.StatusFault.GENERAL_FAULT; } else if (provider === 'airnow' || provider === 'aqicn') { // Set the main AirQuality using the overall AQI value if (provider === 'aqicn') { // For AQICN, use the main aqi value for overall air quality const mainAqi = this.deviceStatus.aqi; if (typeof mainAqi === 'number' && !Number.isNaN(mainAqi)) { this.AirQualitySensor.AirQuality = HomeKitAQI(Math.max(0, mainAqi)); await this.debugLog(`${provider} main AQI: ${mainAqi} -> HomeKit category: ${this.AirQualitySensor.AirQuality}`); } } // Process individual pollutants for their specific density characteristics const pollutants = provider === 'airnow' ? ['O3', 'PM2.5', 'PM10'] : ['o3', 'no2', 'so2', 'pm25', 'pm10', 'co']; let pollutantCount = 0; for (const pollutant of pollutants) { const param = provider === 'airnow' ? this.deviceStatus.find((p) => p.ParameterName === pollutant) : this.deviceStatus.iaqi[pollutant]?.v; if (param !== undefined) { const aqi = provider === 'airnow' ? Number.parseFloat(param.AQI.toString()) : Number.parseFloat(param.toString()); if (!Number.isNaN(aqi)) { pollutantCount++; await this.debugLog(`${provider} ${pollutant} AQI: ${aqi}`); switch (pollutant.toLowerCase()) { case 'o3': this.AirQualitySensor.OzoneDensity = aqi; this.availablePollutants.add('OzoneDensity'); break; case 'pm2.5': case 'pm25': // Handle both formats this.AirQualitySensor.PM2_5Density = aqi; this.availablePollutants.add('PM2_5Density'); break; case 'pm10': this.AirQualitySensor.PM10Density = aqi; this.availablePollutants.add('PM10Density'); break; case 'no2': this.AirQualitySensor.NitrogenDioxideDensity = aqi; this.availablePollutants.add('NitrogenDioxideDensity'); break; case 'so2': this.AirQualitySensor.SulphurDioxideDensity = aqi; this.availablePollutants.add('SulphurDioxideDensity'); break; case 'co': this.AirQualitySensor.CarbonMonoxideLevel = aqi; this.availablePollutants.add('CarbonMonoxideLevel'); break; } // For AirNow, set main AirQuality based on individual pollutant values (existing behavior) if (provider === 'airnow') { this.AirQualitySensor.AirQuality = HomeKitAQI(Math.max(0, aqi)); } } } else { await this.debugLog(`${provider} ${pollutant} data not available`); } } if (pollutantCount === 0) { this.warnLog(`${provider} No pollutant data found in response. Available iaqi keys: ${provider === 'aqicn' ? JSON.stringify(Object.keys(this.deviceStatus.iaqi || {})) : 'N/A'}`); } else { this.infoLog(`${provider} air quality AQI is: ${this.AirQualitySensor.AirQuality} (${pollutantCount} pollutants found)`); } this.AirQualitySensor.StatusFault = this.hap.Characteristic.StatusFault.NO_FAULT; } else { await this.errorLog('Unknown air quality provider: %s.', provider); } } catch (e) { await this.errorLog(`failed to parseStatus, Error Message: ${JSON.stringify(e.message ?? e)}`); await this.apiError(e); } } /** * Reverse geocode lat/long to get zip code using Nominatim (OpenStreetMap) * This is used as a fallback when lat/long endpoint fails */ async reverseGeocodeToZipCode(latitude, longitude) { try { await this.debugLog(`Attempting reverse geocoding for coordinates: ${latitude}, ${longitude}`); const geocodeUrl = `https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`; const { body, statusCode } = await request(geocodeUrl, { headers: { 'User-Agent': 'homebridge-air/1.0', }, headersTimeout: REQUEST_TIMEOUT_CONFIG.GEOCODE_TIMEOUT, bodyTimeout: REQUEST_TIMEOUT_CONFIG.GEOCODE_TIMEOUT, dispatcher: defaultApiAgent, }); if (statusCode === 200) { const responseText = await body.text(); const data = JSON.parse(responseText); if (data.address) { const zipCode = data.address.postcode; const city = data.address.city || data.address.town || data.address.village || data.address.county; if (zipCode && city) { await this.infoLog(`Reverse geocoding successful: ${city}, ZIP ${zipCode}`); return { zipCode, city }; } } } await this.debugLog(`Reverse geocoding failed or incomplete data received`); return null; } catch (error) { await this.debugLog(`Reverse geocoding error: ${error.message}`); return null; } } /** * Asks the Air API for the latest device information */ async refreshStatus() { try { // Check cache first to reduce API calls and follow AirNow best practices const currentTime = Date.now(); if (this.lastResponseData && (currentTime - this.lastRequestTime) < this.cacheMaxAge) { const cacheAge = Math.round((currentTime - this.lastRequestTime) / 1000); await this.debugLog(`Using cached response (${cacheAge}s old, cache max: ${this.cacheMaxAge / 1000}s)`); this.deviceStatus = this.lastResponseData; await this.parseStatus(); await this.updateHomeKitCharacteristics(); return; } // Reset API call counter every hour (rate limiting per AirNow guidelines) if (currentTime > this.apiCallResetTime) { await this.debugLog(`Resetting API call counter (made ${this.apiCallCount} calls last hour)`); this.apiCallCount = 0; this.apiCallResetTime = currentTime + REQUEST_RATE_LIMIT_CONFIG.CALL_WINDOW_MS; } // Check rate limit (conservative limit to avoid issues) // AirNow recommends caching and limiting calls since observations update hourly const maxCallsPerHour = REQUEST_RATE_LIMIT_CONFIG.MAX_CALLS_PER_WINDOW; if (this.apiCallCount >= maxCallsPerHour) { const timeUntilReset = Math.round((this.apiCallResetTime - currentTime) / 60000); await this.warnLog(`API rate limit reached (${this.apiCallCount} calls). Using cached data. Resets in ${timeUntilReset} min`); if (this.lastResponseData) { this.deviceStatus = this.lastResponseData; await this.parseStatus(); await this.updateHomeKitCharacteristics(); } return; } // Increment API call counter this.apiCallCount++; await this.debugLog(`API call ${this.apiCallCount}/${maxCallsPerHour} this hour`); // Use correct AirNow API endpoint paths from official docs // https://docs.airnowapi.org/CurrentObservationsByZip/docs // https://docs.airnowapi.org/CurrentObservationsByLatLon/docs const AirNowCurrentObservationBy = this.device.latitude && this.device.longitude ? `latLong` : 'zipCode'; // Support flexible AQICN URL patterns: geo coordinates, city names, and full URL paths const AqicnCurrentObservationBy = resolveAqicnLocationSegment(this.device); const AirNowCurrentObservationByValue = this.device.latitude && this.device.longitude ? `latitude=${this.device.latitude}&longitude=${this.device.longitude}` : `zipCode=${this.device.zipCode}`; const distance = this.device.distance || '25'; // Default distance of 25 miles if not specified // Use correct format as per official AirNow API docs const providerUrls = { airnow: `${AirNowUrl}${AirNowCurrentObservationBy}/current/?format=application/json&${AirNowCurrentObservationByValue}&distance=${distance}&API_KEY=${this.device.apiKey}`, aqicn: `${AqicnUrl}${AqicnCurrentObservationBy}${AqicnCurrentObservationBy ? '/' : ''}?token=${this.device.apiKey}`, }; const url = providerUrls[this.device.provider]; await this.debugSuccessLog(`url: ${JSON.stringify(url)}`); if (url) { const { body, statusCode, headers } = await this.executeApiRequestWithFallback(url); let response; try { const responseText = await body.text(); await this.debugLog(`Raw response (length: ${responseText.length}): ${responseText}`); await this.debugWarnLog(`statusCode: ${JSON.stringify(statusCode)}`); // Check for redirects (3xx status codes) - try fallback to zip code lookup if (statusCode >= 300 && statusCode < 400) { const location = headers.location; await this.warnLog(`API returned redirect (${statusCode}). Location: ${location || 'not provided'}`); // If using lat/lon with AirNow, try reverse geocoding to get zip code as fallback if (this.device.provider === 'airnow' && this.device.latitude && this.device.longitude) { await this.infoLog(`Attempting reverse geocoding to find zip code as fallback...`); const geoData = await this.reverseGeocodeToZipCode(this.device.latitude, this.device.longitude); if (geoData?.zipCode) { await this.infoLog(`Found zip code ${geoData.zipCode} for ${geoData.city}. Retrying with zip code...`); // Temporarily update device config to use zip code const originalZipCode = this.device.zipCode; this.device.zipCode = geoData.zipCode; this.device.city = geoData.city; // Build new URL with zip code const fallbackUrl = `${AirNowUrl}ByZipCode/current/?format=application/json&zipCode=${geoData.zipCode}&distance=${distance}&API_KEY=${this.device.apiKey}`; await this.debugLog(`Fallback URL: ${fallbackUrl}`); try { const fallbackResponse = await this.executeApiRequestWithFallback(fallbackUrl); const fallbackText = await fallbackResponse.body.text(); if (fallbackResponse.statusCode === 200 && fallbackText && fallbackText.trim().length > 0) { response = JSON.parse(fallbackText); await this.successLog(`Fallback to zip code successful! Using ${geoData.city}, ${geoData.zipCode}`); // Process the successful response this.deviceStatus = response; this.lastResponseData = response; this.lastRequestTime = Date.now(); await this.parseStatus(); await this.updateHomeKitCharacteristics(); return; } else { await this.warnLog(`Fallback zip code lookup also failed (Status: ${fallbackResponse.statusCode})`); } } catch (fallbackError) { await this.debugLog(`Fallback zip code request failed: ${fallbackError.message}`); } finally { // Restore original zip code if we had one if (originalZipCode) { this.device.zipCode = originalZipCode; } } } else { await this.warnLog(`Could not determine zip code from coordinates`); } } await this.errorLog(`The AirNow API endpoint may have changed or requires different parameters.`); await this.debugLog(`Try using zipCode in your config, or check if your API key is valid.`); this.AirQualitySensor.StatusFault = this.hap.Characteristic.StatusFault.GENERAL_FAULT; return; } if (!responseText || responseText.trim().length === 0) { // Try reverse geocoding fallback for empty responses too if (this.device.provider === 'airnow' && this.device.latitude && this.device.longitude && !this.device.zipCode) { await this.infoLog(`Empty response - attempting reverse geocoding fallback...`); const geoData = await this.reverseGeocodeToZipCode(this.device.latitude, this.device.longitude); if (geoData?.zipCode) { await this.infoLog(`Found zip code ${geoData.zipCode}. Retrying with zip code...`); this.device.zipCode = geoData.zipCode; this.device.city = geoData.city; const fallbackUrl = `${AirNowUrl}ByZipCode/current/?format=application/json&zipCode=${geoData.zipCode}&distance=${distance}&API_KEY=${this.device.apiKey}`; try { const fallbackResponse = await this.executeApiRequestWithFallback(fallbackUrl); const fallbackText = await fallbackResponse.body.text(); if (fallbackResponse.statusCode === 200 && fallbackText && fallbackText.trim().length > 0) { response = JSON.parse(fallbackText); await this.successLog(`Fallback to zip code successful! Will use ${geoData.city}, ${geoData.zipCode} going forward`); this.deviceStatus = response; this.lastResponseData = response; this.lastRequestTime = Date.now(); await this.parseStatus(); await this.updateHomeKitCharacteristics(); return; } } catch (fallbackError) { await this.debugLog(`Fallback zip code request failed: ${fallbackError.message}`); } } } await this.errorLog(`Empty response body received from ${this.device.provider} API (Status: ${statusCode})`); await this.errorLog(`This usually means no air quality data is available for your location.`); await this.errorLog(`Try adjusting the distance parameter or verify your coordinates are correct.`); await this.debugLog(`Current settings - Lat: ${this.device.latitude}, Lon: ${this.device.longitude}, Distance: ${distance}`); this.AirQualitySensor.StatusFault = this.hap.Characteristic.StatusFault.GENERAL_FAULT; return; } response = JSON.parse(responseText); } catch (parseError) { await this.errorLog(`Failed to parse JSON response from ${this.device.provider} API: ${parseError.message}`); await this.debugLog(`Parse error details: ${JSON.stringify({ code: parseError.code, name: parseError.name })}`); this.AirQualitySensor.StatusFault = this.hap.Characteristic.StatusFault.GENERAL_FAULT; return; } await this.debugLog(`response: ${JSON.stringify(response)}`); if (statusCode !== 200) { const errorMessage = `${this.device.provider === 'airnow' ? 'AirNow' : 'World Air Quality Index'} API returned status ${statusCode}`; await this.errorLog(`${errorMessage} for provider %s.`, this.device.provider); this.AirQualitySensor.StatusFault = this.hap.Characteristic.StatusFault.GENERAL_FAULT; await this.debugLog(`Error response: ${JSON.stringify(response)}`); await this.apiError(response); } else { // Validate response structure before processing if (!response) { await this.errorLog(`Empty response received from ${this.device.provider} API`); this.AirQualitySensor.StatusFault = this.hap.Characteristic.StatusFault.GENERAL_FAULT; return; } if (this.device.provider === 'aqicn') { const aqicnResponse = response; if (aqicnResponse.status !== 'ok' || !aqicnResponse.data) { const statusMessage = aqicnResponse.status || 'unknown'; await this.errorLog(`AQICN API Error - Status: ${statusMessage}`); this.AirQualitySensor.StatusFault = this.hap.Characteristic.StatusFault.GENERAL_FAULT; await this.apiError(aqicnResponse); return; } // Additional validation for AQICN data structure if (!aqicnResponse.data.aqi && aqicnResponse.data.aqi !== 0) { await this.errorLog(`AQICN API Error - Missing AQI data in response`); this.AirQualitySensor.StatusFault = this.hap.Characteristic.StatusFault.GENERAL_FAULT; return; } this.deviceStatus = aqicnResponse.data; // Cache the successful response (following AirNow best practices for hourly updates) this.lastResponseData = aqicnResponse.data; this.lastRequestTime = Date.now(); await this.debugLog(`Data cached. Will reuse for ${this.cacheMaxAge / 1000}s (AirNow updates hourly)`); } else { // Validate AirNow response structure const airnowResponse = response; if (!Array.isArray(airnowResponse) || airnowResponse.length === 0) { await this.errorLog(`AirNow API Error - Invalid response structure or empty data`); this.AirQualitySensor.StatusFault = this.hap.Characteristic.StatusFault.GENERAL_FAULT; return; } this.deviceStatus = airnowResponse; // Cache the successful response (following AirNow best practices for hourly updates) this.lastResponseData = airnowResponse; this.lastRequestTime = Date.now(); await this.debugLog(`Data cached. Will reuse for ${this.cacheMaxAge / 1000}s (AirNow updates hourly)`); this.lastRequestTime = Date.now(); } await this.parseStatus(); } } else { await this.errorLog('Unknown air quality provider: %s.', this.device.provider); } await this.updateHomeKitCharacteristics(); } catch (e) { // Improve error message handling for different error types const errorMessage = e?.message || e?.code || e?.name || 'Unknown error'; // Handle specific error types for better debugging if (e?.code === 'UND_ERR_CONNECT_TIMEOUT' || e?.code === 'ETIMEDOUT') { await this.errorLog(`API request timeout for ${this.device.provider} - check network connectivity`); this.AirQualitySensor.StatusFault = this.hap.Characteristic.StatusFault.GENERAL_FAULT; } else if (e?.code === 'ENOTFOUND' || e?.code === 'ECONNREFUSED') { await this.errorLog(`Network error for ${this.device.provider} API - ${errorMessage}`); this.AirQualitySensor.StatusFault = this.hap.Characteristic.StatusFault.GENERAL_FAULT; } else { await this.errorLog(`Failed to update status for ${this.device.provider}, Error: ${errorMessage}`); this.AirQualitySensor.StatusFault = this.hap.Characteristic.StatusFault.GENERAL_FAULT; } // Log additional context for debugging (limit to avoid performance issues) const limitedError = { message: e?.message, code: e?.code, name: e?.name, }; await this.debugLog(`Error object: ${JSON.stringify(limitedError)}`); await this.debugLog(`Provider: ${this.device.provider}, City: ${this.device.city || 'N/A'}`); await this.apiError(e); } } isTimeoutError(error) { const directCode = error?.code; const directName = error?.name; const nestedTimeout = Array.isArray(error?.errors) && error.errors.some((nested) => nested?.code === 'ETIMEDOUT' || nested?.code === 'UND_ERR_CONNECT_TIMEOUT'); return directCode === 'ETIMEDOUT' || directCode === 'UND_ERR_CONNECT_TIMEOUT' || directName === 'AggregateError' || nestedTimeout; } async executeApiRequestWithFallback(url) { const requestOptions = { headersTimeout: REQUEST_TIMEOUT_CONFIG.DEFAULT_TIMEOUT, bodyTimeout: REQUEST_TIMEOUT_CONFIG.DEFAULT_TIMEOUT, dispatcher: defaultApiAgent, }; try { return await request(url, requestOptions); } catch (error) { if (!this.isTimeoutError(error)) { throw error; } await this.debugWarnLog('Request timeout detected, retrying with network family auto-selection disabled'); return request(url, { headersTimeout: REQUEST_TIMEOUT_CONFIG.DEFAULT_TIMEOUT, bodyTimeout: REQUEST_TIMEOUT_CONFIG.DEFAULT_TIMEOUT, dispatcher: noFamilyAutoSelectAgent, }); } } /** * Updates the status for each of the HomeKit Characteristics */ async updateHomeKitCharacteristics() { // AirQuality (always available) await this.updateCharacteristic(this.AirQualitySensor.Service, this.hap.Characteristic.AirQuality, this.AirQualitySensor.AirQuality, 'AirQuality'); // Only update characteristics for pollutants that have data available if (this.availablePollutants.has('OzoneDensity')) { await this.updateCharacteristic(this.AirQualitySensor.Service, this.hap.Characteristic.OzoneDensity, this.AirQualitySensor.OzoneDensity, 'OzoneDensity'); } if (this.availablePollutants.has('NitrogenDioxideDensity')) { await this.updateCharacteristic(this.AirQualitySensor.Service, this.hap.Characteristic.NitrogenDioxideDensity, this.AirQualitySensor.NitrogenDioxideDensity, 'NitrogenDioxideDensity'); } if (this.availablePollutants.has('SulphurDioxideDensity')) { await this.updateCharacteristic(this.AirQualitySensor.Service, this.hap.Characteristic.SulphurDioxideDensity, this.AirQualitySensor.SulphurDioxideDensity, 'SulphurDioxideDensity'); } if (this.availablePollutants.has('PM2_5Density')) { await this.updateCharacteristic(this.AirQualitySensor.Service, this.hap.Characteristic.PM2_5Density, this.AirQualitySensor.PM2_5Density, 'PM2_5Density'); } if (this.availablePollutants.has('PM10Density')) { await this.updateCharacteristic(this.AirQualitySensor.Service, this.hap.Characteristic.PM10Density, this.AirQualitySensor.PM10Density, 'PM10Density'); } // Only update CarbonMonoxideLevel if CO data is available if (this.availablePollutants.has('CarbonMonoxideLevel')) { await this.updateCharacteristic(this.AirQualitySensor.Service, this.hap.Characteristic.CarbonMonoxideLevel, this.AirQualitySensor.CarbonMonoxideLevel, 'CarbonMonoxideLevel'); } // StatusFault (always available) await this.updateCharacteristic(this.AirQualitySensor.Service, this.hap.Characteristic.StatusFault, this.AirQualitySensor.StatusFault, 'StatusFault'); } // eslint-disable-next-line unused-imports/no-unused-vars async apiError(_e) { // Set StatusFault to indicate an error state - don't set measurement characteristics to error objects this.AirQualitySensor.Service.updateCharacteristic(this.hap.Characteristic.StatusFault, this.hap.Characteristic.StatusFault.GENERAL_FAULT); } } //# sourceMappingURL=airqualitysensor.js.map