@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
JavaScript
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