Skip to content

Instantly share code, notes, and snippets.

@alexdlaird
Last active December 4, 2018 18:07
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save alexdlaird/b3daa5be9df02c9de983362b8094515b to your computer and use it in GitHub Desktop.
Save alexdlaird/b3daa5be9df02c9de983362b8094515b to your computer and use it in GitHub Desktop.
Texting service to receive current air quality conditions and maps, powered by AirNow, Twilio, and AWS
/*
* This Twilio Function is no longer recommend as it does not put a cache in front of requests to
* AirNow's servers, and we want to minimize the load on their servers. However, it is still presented
* for educational purposes.
*
* To use this code in a Twilio Function, define a Twilio Runtime environment variable of
* `AIRNOW_API_KEY`, then ensure the dependency of `request` for version `2.88.0` is present.
*
* Create a phone number and set it up. Under "Messaging", select "Function" for when
* "A Message Comes In", and point it to the Function created above.
*
* You can now text a zip code (optionally along with the word "map") to the Twilio number to get
* Air Quality Index reports.
*/
const request = require('request');
const AQI_MESSAGES = {
'Good': 'Air quality is considered satisfactory, and air pollution poses little or no risk.',
'Moderate': 'Air quality is acceptable; however, for some pollutants there may be a moderate health concern for a very small number of people. For example, people who are unusually sensitive to ozone may experience respiratory symptoms.',
'Unhealthy for Sensitive Groups': 'Although general public is not likely to be affected at this AQI range, people with lung disease, older adults and children are at a greater risk from exposure to ozone, whereas persons with heart and lung disease, older adults and children are at greater risk from the presence of particles in the air.',
'Unhealthy': 'Everyone may begin to experience some adverse health effects, and members of the sensitive groups may experience more serious effects.',
'Very Unhealthy': 'This would trigger a health alert signifying that everyone may experience more serious health effects.',
'Hazardous': 'This would trigger a health warnings of emergency conditions. The entire population is more likely to be affected.'
}
// A static cache of some of the most affected areas. Yes, this is ugly :)! Ideally, a dynamically generated
// cache would be much preferred, so porting this to AWS Lambda may be a better solution, but this is a quick fix
// to reducing AirNow's load impact for now.
const REPORT_AREA_TO_REGION_IMG = {
// SF
'Oakland': 'cur_aqi_sanfrancisco_ca.jpg',
'San Francisco': 'cur_aqi_sanfrancisco_ca.jpg',
'San Jose': 'cur_aqi_sanfrancisco_ca.jpg',
'Concord': 'cur_aqi_sanfrancisco_ca.jpg',
'Redwood City': 'cur_aqi_sanfrancisco_ca.jpg',
'Fremont': 'cur_aqi_sanfrancisco_ca.jpg',
'San Rafael': 'cur_aqi_sanfrancisco_ca.jpg',
'Fairfield': 'cur_aqi_sanfrancisco_ca.jpg',
'Livermore': 'cur_aqi_sanfrancisco_ca.jpg',
'Napa': 'cur_aqi_sanfrancisco_ca.jpg',
'Santa Rosa': 'cur_aqi_sanfrancisco_ca.jpg',
// Sacramento
'Sacramento': 'cur_aqi_sacramento_ca.jpg',
'Auburn': 'cur_aqi_sacramento_ca.jpg',
'Placerville': 'cur_aqi_sacramento_ca.jpg',
// LA
'Los Angeles': 'cur_aqi_losangeles_ca.jpg',
'Anaheim': 'cur_aqi_losangeles_ca.jpg',
'Malibu': 'cur_aqi_losangeles_ca.jpg',
'Santa Ana': 'cur_aqi_losangeles_ca.jpg',
'Irvine': 'cur_aqi_losangeles_ca.jpg',
'San Bernardino': 'cur_aqi_losangeles_ca.jpg',
'Chino': 'cur_aqi_losangeles_ca.jpg',
'Riverside': 'cur_aqi_losangeles_ca.jpg',
'Perris Vly': 'cur_aqi_losangeles_ca.jpg',
'E San Fernando Vly': 'cur_aqi_losangeles_ca.jpg',
'W San Fernando Vly': 'cur_aqi_losangeles_ca.jpg',
'Santa Clarita Vly': 'cur_aqi_losangeles_ca.jpg',
'NW Coastal LA': 'cur_aqi_losangeles_ca.jpg',
'SW Coastal LA': 'cur_aqi_losangeles_ca.jpg',
'South Coastal LA': 'cur_aqi_losangeles_ca.jpg',
'Central LA CO': 'cur_aqi_losangeles_ca.jpg',
// Ventura
'Oxnard': 'cur_aqi_ventura.jpg',
'Simi Valley': 'cur_aqi_ventura.jpg',
'Santa Paula': 'cur_aqi_ventura.jpg',
'Piru': 'cur_aqi_ventura.jpg',
'Thousand Oaks': 'cur_aqi_ventura.jpg',
// CA/NV
'Woodland': 'cur_aqi_ca_nv.jpg',
'Yuba City/Marysville': 'cur_aqi_ca_nv.jpg',
'Chico': 'cur_aqi_ca_nv.jpg',
'Stockton': 'cur_aqi_ca_nv.jpg',
'Sloughhouse': 'cur_aqi_ca_nv.jpg',
'Santa Maria': 'cur_aqi_ca_nv.jpg',
'Santa Barbara': 'cur_aqi_ca_nv.jpg',
'San Luis Obispo': 'cur_aqi_ca_nv.jpg',
'Modesto': 'cur_aqi_ca_nv.jpg',
'Davis': 'cur_aqi_ca_nv.jpg',
'Merced': 'cur_aqi_ca_nv.jpg',
'Rio Vista': 'cur_aqi_ca_nv.jpg',
'Sequoia/Kings Canyon National Parks': 'cur_aqi_ca_nv.jpg',
'Vacaville': 'cur_aqi_ca_nv.jpg',
'Goleta': 'cur_aqi_ca_nv.jpg',
'Fresno': 'cur_aqi_ca_nv.jpg'
}
exports.handler = function(context, event, callback) {
let twiml = new Twilio.twiml.MessagingResponse();
let zipCode = event.Body.toLowerCase();
const includeMap = zipCode.includes("map");
// Check to ensure the message is valid (a zip code with an optional "map" at the end)
if (!/^\d+(( )?map)?$/i.test(zipCode)) {
twiml.message('Send us a zip code and we\'ll reply with the area\'s Air Quality Index (AQI). Put "map" at the end and we\'ll include the regional map too.');
callback(null, twiml);
return;
}
if (includeMap) {
zipCode = zipCode.split("map")[0].trim();
}
console.log('zipCode:', zipCode);
console.log('includeMap:', includeMap);
// Get the current conditions from the AirNow API
const airNowApiUrl = 'http://www.airnowapi.org/aq/observation/zipCode/current/?format=application/json&zipCode=' + zipCode + '&distance=25&API_KEY=' + context.AIRNOW_API_KEY;
request(airNowApiUrl, {timeout: 2500}, function (error, response, body) {
console.log('error:', error);
console.log('statusCode:', response && response.statusCode);
console.log('body:', body);
// AirNow's API is significantly more stanble than their site, but it still fails
// often due to overload (especially in fire season), so protect against that
if (error !== null) {
if (error.code === 'ETIMEDOUT' || error.code === 'ESOCKETTIMEDOUT') {
console.log('AirNow seems overloaded, the request for any data failed');
twiml.message('Oops, something went wrong. AirNow seems overloaded at this time.');
} else {
console.log('An unknown AirNow error occurred');
twiml.message('Oops, something went wrong. AirNow may be unavailable at this time.');
}
callback(null, twiml);
return;
}
const data = JSON.parse(body);
console.log('data:', data);
// Identify the PM2.5 (if present) parameter, which contains the AQI, in the response
let aqiParam;
for (let i = 0; i < data.length; ++i) {
console.log(data[i]);
if (data[i]['ParameterName'] == 'PM2.5') {
aqiParam = data[i];
break;
}
}
if (typeof aqiParam !== 'undefined') {
console.log('reportArea:', aqiParam['ReportingArea'])
// Clean up the time format
const suffix = (aqiParam['HourObserved'] >= 12) ? 'PM' : 'AM';
let time = (aqiParam['HourObserved'] > 12) ? aqiParam['HourObserved'] - 12 : aqiParam['HourObserved'];
time = ((time == '00') ? 12 : time) + suffix + ' ' + aqiParam['LocalTimeZone'];
const message = twiml.message();
message.body(aqiParam['Category']['Name'] + ' AQI of ' + aqiParam['AQI'] + ' for ' + aqiParam['ReportingArea'] + ' at ' + time + '. ' + AQI_MESSAGES[aqiParam['Category']['Name']] + '\nSource: AirNow');
// If the user requested a map, build the media URL to send back with the Twilm response
if (includeMap) {
const mapUrlPrefix = 'https://files.airnowtech.org/airnow/today/';
// As much as possible, we're trying to ease the load on AirNow's servers, so
// check if this region is in our static cache
if (aqiParam['ReportingArea'] in REPORT_AREA_TO_REGION_IMG) {
let mapUrl = mapUrlPrefix + REPORT_AREA_TO_REGION_IMG[aqiParam['ReportingArea']];
mapUrl = mapUrl.substr(0, mapUrl.indexOf('.jpg') + 4);
console.log('mapUrl (cached):', mapUrl);
message.media(mapUrl);
callback(null, twiml);
} else {
const localAirNowUrl = 'https://airnow.gov/index.cfm?action=airnow.local_city&zipcode=' + zipCode + '&submit=Go';
request(localAirNowUrl, {timeout: 2000}, function (error, response, body) {
console.log('mapError:', error);
console.log('mapStatusCode:', response && response.statusCode);
if (error !== null) {
console.log('AirNow map rendering seems to have errored');
callback(null, twiml);
return;
}
let mapUrl = body.substr(body.indexOf(mapUrlPrefix));
mapUrl = mapUrl.substr(0, mapUrl.indexOf('.jpg') + 4);
console.log('mapUrl:', mapUrl);
message.media(mapUrl);
callback(null, twiml);
});
}
} else {
callback(null, twiml);
}
} else {
console.log('AirNow gave us an unexpected response, probably unavailable for zip code ' + zipCode);
twiml.message('Oops, something went wrong. AirNow may be unavailable for this zip code.');
callback(null, twiml);
}
});
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment