Skip to content

Commit

Permalink
Integration/airqoon (#38)
Browse files Browse the repository at this point in the history
* add airqoon

* airqoon
  • Loading branch information
russbiggs authored Oct 24, 2024
1 parent 79c326b commit 1372eee
Show file tree
Hide file tree
Showing 5 changed files with 287 additions and 4 deletions.
3 changes: 2 additions & 1 deletion fetcher/lib/measurand.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ class Measurand {
{
ppb: ['ppm', (val) => val / 1000],
'ng/m³': ['µg/m³', (val) => val / 1000],
pp100ml: ['particles/cm³', (val) => val / 100]
pp100ml: ['particles/cm³', (val) => val / 100],
'pa': ['hPa', (val) => val / 100]
}[this.unit] || [this.unit, (val) => val]
);
}
Expand Down
1 change: 0 additions & 1 deletion fetcher/lib/measure.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ class Measures {
if (!this.from || measure.timestamp < this.from) {
this.from = measure.timestamp;
}

this.measures.push(measure);
}

Expand Down
2 changes: 0 additions & 2 deletions fetcher/lib/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,6 @@ class Providers {
const filename = id || `${Math.floor(Date.now() / 1000)}-${Math.random().toString(36).substring(8)}`;
const Key = `${process.env.STACK}/measures/${provider}/${today}/${filename}.json.gz`;
const compressedString = await gzip(JSON.stringify(data));


if (DRYRUN) {
console.log(`Would have saved ${data.measures.length} measurements and ${data.locations.length} stations to '${Bucket}/${Key}'`);
putFile(compressedString, Key);
Expand Down
275 changes: 275 additions & 0 deletions fetcher/providers/airqoon.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
const Providers = require('../lib/providers');
const { request } = require('../lib/utils');
const { Measures, FixedMeasure } = require('../lib/measure');
const { Measurand } = require('../lib/measurand');


class AirQoonApi {
/**
*
* @param {Source} source
* @param {Organization} org
*/
constructor(source) {
this.fetched = false;
this.source = source;
this._measurands = null;
this._measures = null;
this.gateways = {};
this.parameters = {
'PM25Calibrated': ['pm25', 'ug/m3'],
'PM10Calibrated': ['pm10', 'ug/m3'],
'NO2UGM3CalibratedFiltered': ['no2', 'ug/m3'],
'O3V2UGM3CalibratedFiltered': ['o3', 'ug/m3'],
'Humidity': ['relativehumidity', '%'],
'Pressure': ['pressure', 'pa'],
'Temperature': ['temperature', 'c']
};
this.measures = new Measures(FixedMeasure);
this.locations = [];
this.token;
}

get username() {
return this.source.username;
}

get password() {
return this.source.password;
}


get provider() {
return this.source.provider;
}

get baseUrl() {
return this.source.meta.url;
}

async fetchToken() {
const url = `${this.baseUrl}/v1/auth/login`;
const body = {
'Username': this.username,
'Password': this.password
};
const response = await request({
url,
json: true,
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: body
});
this.token = response.body.Token;
}

async fetchMeasurands() {
this.measurands = await Measurand.getIndexedSupportedMeasurands(this.parameters);
}


/**
* Provide a sensor based ingest id
* @param {number} deviceId
* @param {object} m
* @returns {string}
*/
getSensorId(deviceId, m) {
const measurand = this.measurands[m];
if (!measurand) {
throw new Error(`Could not find measurand for ${m}`);
}
return `airqoon-${deviceId}-${measurand.parameter}`;
}

getDeviceId(device) {
return `airqoon-${device.Id}`;
}

normalize(meas) {
const measurand = this.measurands[meas.measurand];
return measurand.normalize_value(parseFloat(meas.Value.replace(/,/g, '')));
}

async fetchDevices() {
const url = `${this.baseUrl}/v1/devices?limit=100`;

const response = await request({
url,
json: true,
method: 'GET',
headers: {
'Authorization': `Bearer ${this.token}`
},
gzip: true
});

return response;

}

async fetchMeasurements(deviceId) {
const url = `${this.baseUrl}/v1/devices/id/${deviceId}/telemetry/hourly`;

const response = await request({
url,
json: true,
method: 'GET',
headers: {
'Authorization': `Bearer ${this.token}`
},
gzip: true
});

return response;

}

async fetchData() {

await this.fetchMeasurands();

const devices = await this.fetchDevices();


devices.body.Data.map((d) => {
try {
this.locations.push({
location: this.getDeviceId(d),
label: d.Name,
ismobile: false,
lon: d.Location.Longitude,
lat: d.Location.Latitude
});
} catch (e) {
console.warn(`Error adding location: ${e.message}`);
}
});

for (const d of devices.body.Data) {
const hourly = await this.fetchMeasurements(d.Id);
if (hourly.statusCode !== 200) {
continue;
}
const measurements = Object.keys(hourly.body).map((measurand) => ({
measurand,
...hourly.body[measurand]
})).filter((o) => Object.keys(this.parameters).includes(o.measurand));
measurements.map((m) => {
this.measures.push({
sensor_id: this.getSensorId(d.Id, m.measurand),
measure: this.normalize(m),
timestamp: m.DateTime,
flags: { }
});
});
}

this.fetched = true;
}

data() {
if (!this.fetched) {
console.warn('Data has not been fetched');
}
return {
meta: {
schema: 'v0.1',
source: 'airqoon',
matching_method: 'ingest-id'
},
measures: this.measures.measures,
locations: this.locations
};
}

summary() {
if (!this.fetched) {
console.warn('Data has not been fetched');
return {
source_name: this.source.provider,
message: 'Data has not been fetched'
};
} else {
return {
source_name: this.source.provider,
locations: this.locations.length,
measures: this.measures.length,
from: this.measures.from,
to: this.measures.to
};
}
}
}




module.exports = {
async processor(source) {

// create new smartsense object
const client = new AirQoonApi(source);
// fetch auth token
await client.fetchToken();
// fetch and process the data
await client.fetchData();
// and then push it to the
Providers.put_measures_json(client.provider, client.data());

return client.summary();
}
};

/**
* @typedef {Object} Organization
*
* @property {String} apiKey
* @property {String} organizationName
*/

/**
* @typedef {Object} Device
*
* @property {String} _id
* @property {String} code
* @property {('purchased'|'configured'|'working'|'decommisioned')} lifeStage
* @property {String[]} enabledCharacteristics
* @property {Object} state
* @property {Object} location
* @property {Boolean} indoor
* @property {String} workingStartAt
* @property {String} lastReadingReceivedAt
* @property {('nominal'|'degraded'|'critical')} sensorsHealthStatus
* @property {('needsSetup'|'needsAttention'|'healthy')} overallStatus
*/


/**
* @typedef {Object} Gateway
*
* @property {String} uid
* @property {String} name
* @property {String} longitude
* @property {String} latitude
*/

/**
* @typedef {Object} Datasource
*
* @property {String} uid unique id of the gateway
* @property {String} deviceCode The short ID of the device that produced the Measurement, usually starting with "A".
* @property {String} sourceType A Clarity device "CLARITY_NODE" or government reference site "REFERENCE_SITE"
* @property {String} [name] The name assigned to the data source by the organization. If the dataSource is not named, the underlying deviceCode is returned. Optional.
* @property {String} [group] The group assigned to the data source by the organization, or null if no group. Optional.
* @property {String[]} [tags] Identifying tages assigned to the data source by the organization. Optional.
* @property {('active'|'expired')} subscriptionStatus
* @property {String} subscriptionExpirationDate When the subscription to this gateway will expire
*/

/**
* @typedef {Device | Datasource} AugmentedDevice
*/
10 changes: 10 additions & 0 deletions fetcher/sources/airqoon.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"schema": "v1",
"provider": "airqoon",
"frequency": "minute",
"secretKey": "airqoon-credentials",
"active": true,
"meta": {
"url": "https://base-external-api.airqoon.com"
}
}

0 comments on commit 1372eee

Please sign in to comment.