UNPKG

@homebridge-plugins/homebridge-air

Version:

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

199 lines 8.48 kB
import { interval } from 'rxjs'; import { skipWhile } from 'rxjs/operators'; import { Agent, request } from 'undici'; import { AirNowUrl, AqicnUrl, HomeKitAQI, REQUEST_RATE_LIMIT_CONFIG, REQUEST_TIMEOUT_CONFIG, resolveAqicnLocationSegment, } from '../settings.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, }, }); /** * AirQualitySensorMatter * * Handles periodic AQI data fetching for a single Air Quality sensor registered * as a Matter accessory. On each polling cycle it fetches from the same provider * URLs used by the HAP AirQualitySensor class, converts the raw AQI value to a * HomeKit-level integer (1-5), and delegates to * {@link AirMatterPlatform.updateMatterAirQuality} which maps it to the Matter * `AirQualityEnum` (0-6) before pushing the state update to Homebridge. */ export class AirQualitySensorMatter { platform; device; uuid; updateInProgress = false; lastRequestTime = 0; lastAqi = null; cacheMaxAge = REQUEST_RATE_LIMIT_CONFIG.CACHE_MAX_AGE; apiCallCount = 0; apiCallResetTime = Date.now() + REQUEST_RATE_LIMIT_CONFIG.CALL_WINDOW_MS; constructor(platform, device, uuid) { this.platform = platform; this.device = device; this.uuid = uuid; const refreshRate = device.refreshRate ?? platform.platformRefreshRate ?? 3600; // Fetch immediately on creation (non-blocking) void this.refreshStatus(); // Start polling interval interval(refreshRate * 1000) .pipe(skipWhile(() => this.updateInProgress)) .subscribe(async () => { await this.refreshStatus(); }); } /** * Fetch the latest AQI data from the provider and push the result to the * registered Matter accessory state. */ async refreshStatus() { if (this.updateInProgress) { return; } this.updateInProgress = true; try { const currentTime = Date.now(); // Return cached value if still fresh if (this.lastAqi !== null && (currentTime - this.lastRequestTime) < this.cacheMaxAge) { const cacheAge = Math.round((currentTime - this.lastRequestTime) / 1000); this.platform.log.debug(`[${this.device.city}] Matter: using cached AQI (${cacheAge}s old)`); await this.platform.updateMatterAirQuality(this.uuid, this.lastAqi); return; } // Reset hourly call counter if (currentTime > this.apiCallResetTime) { this.apiCallCount = 0; this.apiCallResetTime = currentTime + REQUEST_RATE_LIMIT_CONFIG.CALL_WINDOW_MS; } // Honour rate limit (same 60 calls/hour as HAP class) const maxCallsPerHour = REQUEST_RATE_LIMIT_CONFIG.MAX_CALLS_PER_WINDOW; if (this.apiCallCount >= maxCallsPerHour) { if (this.lastAqi !== null) { await this.platform.updateMatterAirQuality(this.uuid, this.lastAqi); } return; } this.apiCallCount++; const url = this.buildUrl(); if (!url) { this.platform.log.error(`[${this.device.city}] Matter: unknown air quality provider '${this.device.provider}'`); return; } const { body, statusCode } = await this.executeApiRequestWithFallback(url); if (statusCode !== 200) { this.platform.log.error(`[${this.device.city}] Matter: ${this.device.provider} API returned status ${statusCode}`); return; } const responseText = await body.text(); if (!responseText || responseText.trim().length === 0) { this.platform.log.error(`[${this.device.city}] Matter: empty response from ${this.device.provider}`); return; } let response; try { response = JSON.parse(responseText); } catch { this.platform.log.error(`[${this.device.city}] Matter: failed to parse JSON response from ${this.device.provider}`); return; } const aqi = this.parseAqi(response); if (aqi !== null) { this.lastAqi = aqi; this.lastRequestTime = Date.now(); await this.platform.updateMatterAirQuality(this.uuid, aqi); this.platform.log.info(`[${this.device.city}] Matter: air quality updated (HomeKit AQI ${aqi})`); } } catch (e) { this.platform.log.error(`[${this.device.city}] Matter: refresh failed – ${e?.message ?? e}`); } finally { this.updateInProgress = false; } } /** * Build the provider API URL using the same logic as AirQualitySensor.refreshStatus. */ buildUrl() { const airNowBy = this.device.latitude && this.device.longitude ? 'latLong' : 'zipCode'; const aqicnBy = resolveAqicnLocationSegment(this.device); const airNowByValue = this.device.latitude && this.device.longitude ? `latitude=${this.device.latitude}&longitude=${this.device.longitude}` : `zipCode=${this.device.zipCode}`; const distance = this.device.distance || '25'; const urls = { airnow: `${AirNowUrl}${airNowBy}/current/?format=application/json&${airNowByValue}&distance=${distance}&API_KEY=${this.device.apiKey}`, aqicn: `${AqicnUrl}${aqicnBy}${aqicnBy ? '/' : ''}?token=${this.device.apiKey}`, }; return urls[this.device.provider]; } /** * Extract the overall AQI from the raw API response and convert it to a * HomeKit AQI level (1-5) using the shared HomeKitAQI helper. */ parseAqi(response) { try { if (this.device.provider === 'aqicn') { const data = response.data; if (!data || (data.aqi !== 0 && !data.aqi)) { return null; } return HomeKitAQI(Math.max(0, data.aqi)); } if (this.device.provider === 'airnow') { const records = response; if (!Array.isArray(records) || records.length === 0) { return null; } const values = records.map(r => r.AQI).filter(v => typeof v === 'number' && !Number.isNaN(v)); if (values.length === 0) { return null; } return HomeKitAQI(Math.max(0, Math.max(...values))); } return null; } catch { return null; } } 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) { try { return await request(url, { headersTimeout: REQUEST_TIMEOUT_CONFIG.DEFAULT_TIMEOUT, bodyTimeout: REQUEST_TIMEOUT_CONFIG.DEFAULT_TIMEOUT, dispatcher: defaultApiAgent, }); } catch (error) { if (!this.isTimeoutError(error)) { throw error; } this.platform.log.warn(`[${this.device.city}] Matter: 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, }); } } } //# sourceMappingURL=airqualitysensormatter.js.map