UNPKG

homebridge-airgradient

Version:

Fetches air quality information from AirGradient devices.

312 lines 15.6 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; const axios_1 = __importDefault(require("axios")); let hap; class AirGradientPlatform { constructor(log, config, api) { var _a, _b; // Keep a stable map of cached and newly-created accessories by UUID this.accessories = new Map(); // Persist sensor configs to act on them after didFinishLaunching this.sensorConfigs = []; this.log = log; this.api = api; this.fetchLogs = (_a = config === null || config === void 0 ? void 0 : config.fetchLogs) !== null && _a !== void 0 ? _a : true; this.verboseLogs = (_b = config === null || config === void 0 ? void 0 : config.verboseLogs) !== null && _b !== void 0 ? _b : true; hap = api.hap; if (Array.isArray(config === null || config === void 0 ? void 0 : config.sensors)) { for (const sensorConfig of config.sensors) { if (sensorConfig === null || sensorConfig === void 0 ? void 0 : sensorConfig.serialno) { this.sensorConfigs.push(sensorConfig); this.log.info('Queued sensor for init with serial number:', sensorConfig.serialno); } } } // Only manipulate accessories after Homebridge has finished launching, so cache restore happens first. this.api.on('didFinishLaunching', () => { this.log.info('Did finish launching'); for (const sensorConfig of this.sensorConfigs) { const uuid = hap.uuid.generate(sensorConfig.serialno); const cached = this.accessories.get(uuid); if (cached) { this.log.info('Restoring existing accessory from cache:', cached.displayName); if (!cached.context.serial) { cached.context.serial = sensorConfig.serialno; } new AirGradientSensor(this, cached, sensorConfig); } else { this.log.info('Adding new accessory for serial number:', sensorConfig.serialno); const accessory = new this.api.platformAccessory(`AirGradient Sensor ${sensorConfig.serialno}`, uuid); accessory.context.serial = sensorConfig.serialno; new AirGradientSensor(this, accessory, sensorConfig); this.api.registerPlatformAccessories('homebridge-airgradient', 'AirGradientPlatform', [accessory]); this.accessories.set(uuid, accessory); } } }); } configureAccessory(accessory) { this.accessories.set(accessory.UUID, accessory); } } function isAirGradientData(x) { if (x === null || typeof x !== 'object') { return false; } const o = x; // Minimal required fields you actually rely on elsewhere return (typeof o.pm02 === 'number' && typeof o.pm10 === 'number' && typeof o.rco2 === 'number' && typeof o.atmp === 'number' && typeof o.rhum === 'number'); } class AirGradientSensor { constructor(platform, accessory, sensorConfig) { var _a, _b; this.data = null; this.platform = platform; this.accessory = accessory; this.log = platform.log; this.serialno = sensorConfig.serialno; this.pollingInterval = (sensorConfig.pollingInterval && sensorConfig.pollingInterval > 0) ? sensorConfig.pollingInterval : 60000; // Default to 1 minute, must be higher than 0 this.useCompensatedValues = (_a = sensorConfig.useCompensatedValues) !== null && _a !== void 0 ? _a : false; this.co2AlertThreshold = (sensorConfig.co2AlertThreshold && sensorConfig.co2AlertThreshold > 0) ? sensorConfig.co2AlertThreshold : 800; this.fetchLogs = platform.fetchLogs; this.verboseLogs = platform.verboseLogs; // Construct the local API URL using the serialno this.apiUrl = `http://airgradient_${this.serialno}.local/measures/current`; this.accessory.getService(hap.Service.AccessoryInformation) .setCharacteristic(hap.Characteristic.Manufacturer, 'AirGradient') .setCharacteristic(hap.Characteristic.SerialNumber, this.serialno); this.service = this.accessory.getService(hap.Service.AirQualitySensor) || this.accessory.addService(hap.Service.AirQualitySensor); this.serviceTemp = this.accessory.getService(hap.Service.TemperatureSensor) || this.accessory.addService(hap.Service.TemperatureSensor); this.serviceCO2 = this.accessory.getService(hap.Service.CarbonDioxideSensor) || this.accessory.addService(hap.Service.CarbonDioxideSensor); this.serviceHumid = this.accessory.getService(hap.Service.HumiditySensor) || this.accessory.addService(hap.Service.HumiditySensor); // Ensure all optional characteristics exist (getCharacteristic ensures creation for optionals) this.service.getCharacteristic(hap.Characteristic.AirQuality); this.service.getCharacteristic(hap.Characteristic.PM2_5Density); this.service.getCharacteristic(hap.Characteristic.PM10Density); if (!this.service.testCharacteristic(hap.Characteristic.VOCDensity)) { this.service.addCharacteristic(hap.Characteristic.VOCDensity); } if (!this.service.testCharacteristic(hap.Characteristic.NitrogenDioxideDensity)) { this.service.addCharacteristic(hap.Characteristic.NitrogenDioxideDensity); } this.serviceCO2.getCharacteristic(hap.Characteristic.CarbonDioxideDetected); this.serviceCO2.getCharacteristic(hap.Characteristic.CarbonDioxideLevel); // Initialize safe placeholder values so the Home hub never sees "missing" nodes this.service.updateCharacteristic(hap.Characteristic.AirQuality, (_b = hap.Characteristic.AirQuality.UNKNOWN) !== null && _b !== void 0 ? _b : hap.Characteristic.AirQuality.FAIR); this.service.updateCharacteristic(hap.Characteristic.PM2_5Density, 0); this.service.updateCharacteristic(hap.Characteristic.PM10Density, 0); this.service.updateCharacteristic(hap.Characteristic.VOCDensity, 0); this.service.updateCharacteristic(hap.Characteristic.NitrogenDioxideDensity, 0); this.serviceCO2.updateCharacteristic(hap.Characteristic.CarbonDioxideDetected, hap.Characteristic.CarbonDioxideDetected.CO2_LEVELS_NORMAL); this.serviceCO2.updateCharacteristic(hap.Characteristic.CarbonDioxideLevel, 0); this.serviceTemp.getCharacteristic(hap.Characteristic.CurrentTemperature); this.serviceTemp.updateCharacteristic(hap.Characteristic.CurrentTemperature, 0); this.serviceHumid.getCharacteristic(hap.Characteristic.CurrentRelativeHumidity); this.serviceHumid.updateCharacteristic(hap.Characteristic.CurrentRelativeHumidity, 0); this.updateData(); } async fetchData() { var _a; try { // Strongly type the expected payload const response = await axios_1.default.get(this.apiUrl, { timeout: 30000, // optional: avoid hanging forever headers: { 'Accept': 'application/json' }, // validateStatus: (s) => s >= 200 && s < 400, // optional: treat 3xx as ok if your devices redirect }); const payload = response.data; // Runtime validation: ensures critical numeric fields exist if (!isAirGradientData(payload)) { if (this.fetchLogs) { if (this.verboseLogs) { this.log.error('AirGradient API returned unexpected data format:', payload); } else { this.log.error('AirGradient API returned unexpected data format.'); } } return; // keep previous this.data (so we don't overwrite with bad data) } // All good—commit and log this.data = payload; if (this.fetchLogs) { if (this.verboseLogs) { this.log.info('Data fetched successfully:', this.data); } else { this.log.info('Data fetched successfully.'); } } // Optional extra debug this.log.debug('API response:', this.data); } catch (err) { if (this.fetchLogs) { // Make axios/network errors readable without losing detail const e = err; if (axios_1.default.isAxiosError(e)) { if (this.verboseLogs) { this.log.error(`Axios error fetching AirGradient data: ${e.message}` + (e.response ? ` (status ${e.response.status})` : '') + (e.code ? ` [code ${e.code}]` : '')); if ((_a = e.response) === null || _a === void 0 ? void 0 : _a.data) { this.log.debug('Error response body:', e.response.data); } } else { const cause = e.cause; const addr = (cause === null || cause === void 0 ? void 0 : cause.address) && (cause === null || cause === void 0 ? void 0 : cause.port) ? ` ${cause.address}:${cause.port}` : ''; const code = (cause === null || cause === void 0 ? void 0 : cause.code) || e.code || ''; const reason = code === 'EHOSTUNREACH' ? 'host unreachable' : code === 'ECONNREFUSED' ? 'connection refused' : code === 'ETIMEDOUT' ? 'timeout' : code === 'ENOTFOUND' ? 'host not found' : e.message; this.log.error(`Error fetching data: ${reason}${addr}`); } } else if (e instanceof Error) { if (this.verboseLogs) { this.log.error('Error fetching data from AirGradient API:', e.message); this.log.debug(e.stack || 'no stack'); } else { this.log.error(`Error fetching data: ${e.message}`); } } else { this.log.error('Unknown error fetching data from AirGradient API:', e); } } throw err; // keep existing control flow in updateData() } } async updateData() { try { await this.fetchData(); if (this.data) { this.updateCharacteristics(); } } catch (_a) { // fetchData already logged the error } finally { // Schedule the next update setTimeout(() => this.updateData(), this.pollingInterval); } } updateCharacteristics() { if (this.data) { // Use compensated values if enabled and available, otherwise fallback to the default values const pm2_5 = this.useCompensatedValues && this.data.pm02Compensated !== undefined ? this.data.pm02Compensated : this.data.pm02; const temp = this.useCompensatedValues && this.data.atmpCompensated !== undefined ? this.data.atmpCompensated : this.data.atmp; const humidity = this.useCompensatedValues && this.data.rhumCompensated !== undefined ? this.data.rhumCompensated : this.data.rhum; // Other values remain the same const pm10 = this.data.pm10; const tvoc = this.data.tvocIndex; const nox = this.data.noxIndex; const co2 = this.data.rco2; if (typeof pm2_5 === 'number' && isFinite(pm2_5)) { this.service.updateCharacteristic(hap.Characteristic.PM2_5Density, pm2_5); } else { this.log.warn('Invalid PM2.5 value:', pm2_5); } if (typeof pm10 === 'number' && isFinite(pm10)) { this.service.updateCharacteristic(hap.Characteristic.PM10Density, pm10); } else { this.log.warn('Invalid PM10 value:', pm10); } if (typeof tvoc === 'number' && isFinite(tvoc)) { this.service.updateCharacteristic(hap.Characteristic.VOCDensity, tvoc); } else { this.log.warn('Invalid TVOC value:', tvoc); } if (typeof nox === 'number' && isFinite(nox)) { this.service.updateCharacteristic(hap.Characteristic.NitrogenDioxideDensity, nox); } else { this.log.warn('Invalid NOx value:', nox); } if (typeof temp === 'number' && isFinite(temp)) { this.serviceTemp.updateCharacteristic(hap.Characteristic.CurrentTemperature, temp); } else { this.log.warn('Invalid Temperature value:', temp); } if (typeof co2 === 'number' && isFinite(co2)) { this.serviceCO2.updateCharacteristic(hap.Characteristic.CarbonDioxideDetected, this.calculateCO2Detected(co2)); this.serviceCO2.updateCharacteristic(hap.Characteristic.CarbonDioxideLevel, co2); } else { this.log.warn('Invalid CO2 value:', co2); } if (typeof humidity === 'number' && isFinite(humidity)) { this.serviceHumid.updateCharacteristic(hap.Characteristic.CurrentRelativeHumidity, humidity); } else { this.log.warn('Invalid Humidity value:', humidity); } this.service.updateCharacteristic(hap.Characteristic.AirQuality, this.calculateAirQuality(pm2_5)); if (this.fetchLogs) { if (this.verboseLogs) { this.log.info(`Updated characteristics - PM2.5: ${pm2_5}, PM10: ${pm10}, TVOC: ${tvoc}, ` + `NOx: ${nox}, TEMP: ${temp}, CO2: ${co2}, Humidity: ${humidity}`); } else { this.log.info('Updated characteristics.'); } } } } calculateAirQuality(pm2_5) { if (pm2_5 <= 12) { return hap.Characteristic.AirQuality.EXCELLENT; } else if (pm2_5 <= 35.4) { return hap.Characteristic.AirQuality.GOOD; } else if (pm2_5 <= 55.4) { return hap.Characteristic.AirQuality.FAIR; } else if (pm2_5 <= 150.4) { return hap.Characteristic.AirQuality.INFERIOR; } else { return hap.Characteristic.AirQuality.POOR; } } calculateCO2Detected(co2) { return co2 > this.co2AlertThreshold ? hap.Characteristic.CarbonDioxideDetected.CO2_LEVELS_ABNORMAL : hap.Characteristic.CarbonDioxideDetected.CO2_LEVELS_NORMAL; } } module.exports = (homebridge) => { hap = homebridge.hap; homebridge.registerPlatform('homebridge-airgradient', 'AirGradientPlatform', AirGradientPlatform); }; //# sourceMappingURL=index.js.map