Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Customized AQI Widget
"use strict";
/**
* This widget is from <https://github.com/jasonsnell/PurpleAir-AQI-Scriptable-Widget>
* By Jason Snell, Rob Silverii, Adam Lickel, Alexander Ogilvie, and Brian Donovan.
* Based on code by Matt Silverlock.
*/
const API_URL = "https://www.purpleair.com";
/**
* Find a nearby PurpleAir sensor ID via https://fire.airnow.gov/
* Click a sensor near your location: the ID is the trailing integers
* https://www.purpleair.com/json has all sensors by location & ID.
* @type {number}
*/
const SENSOR_ID = args.widgetParameter;
const DEFAULT_SENSOR_ID = 69223;
/**
* Widget attributes: AQI level threshold, text label, gradient start and end colors, text color
*
* @typedef {object} LevelAttribute
* @property {number} threshold
* @property {string} label
* @property {string} thresholdColor
*/
/**
* @typedef {object} SensorData
* @property {string} val
* @property {string} adj1
* @property {string} adj2
* @property {number} ts
* @property {string} hum
* @property {string} loc
* @property {string} lat
* @property {string} lon
*/
/**
* @typedef {object} LatLon
* @property {number} latitude
* @property {number} longitude
*/
/**
* Get the closest PurpleAir sensorId to the given location
*
* @returns {Promise<number>}
*/
async function getSensorId() {
if (SENSOR_ID) return SENSOR_ID;
/** @type {LatLon} */
const {latitude, longitude} = await Location.current();
const BOUND_OFFSET = 0.05;
const nwLat = latitude + BOUND_OFFSET;
const seLat = latitude - BOUND_OFFSET;
const nwLng = longitude - BOUND_OFFSET;
const seLng = longitude + BOUND_OFFSET;
const req = new Request(
`${API_URL}/data.json?opt=1/mAQI/a10/cC5&fetch=true&nwlat=${nwLat}&selat=${seLat}&nwlng=${nwLng}&selng=${seLng}&fields=ID`
);
/** @type {{ code?: number; data?: Array<Array<number>>; fields?: Array<string>; }} */
const res = await req.loadJSON();
const RATE_LIMIT = 429;
if (res.code === RATE_LIMIT) return DEFAULT_SENSOR_ID;
const {fields, data} = res;
const sensorIdIndex = fields.indexOf("ID");
const latIndex = fields.indexOf("Lat");
const lonIndex = fields.indexOf("Lon");
const typeIndex = fields.indexOf("Type");
const OUTDOOR = 0;
let closestSensor;
let closestDistance = Infinity;
for (const location of data.filter((datum) => datum[typeIndex] === OUTDOOR)) {
const distanceFromLocation = haversine(
{latitude, longitude},
{latitude: location[latIndex], longitude: location[lonIndex]}
);
if (distanceFromLocation < closestDistance) {
closestDistance = distanceFromLocation;
closestSensor = location;
}
}
return closestSensor ? closestSensor[sensorIdIndex] : DEFAULT_SENSOR_ID;
}
/**
* Returns the haversine distance between start and end.
*
* @param {LatLon} start
* @param {LatLon} end
* @returns {number}
*/
function haversine(start, end) {
const toRadians = (n) => (n * Math.PI) / 180;
const deltaLat = toRadians(end.latitude - start.latitude);
const deltaLon = toRadians(end.longitude - start.longitude);
const startLat = toRadians(start.latitude);
const endLat = toRadians(end.latitude);
const angle =
Math.sin(deltaLat / 2) ** 2 +
Math.sin(deltaLon / 2) ** 2 * Math.cos(startLat) * Math.cos(endLat);
return 2 * Math.atan2(Math.sqrt(angle), Math.sqrt(1 - angle));
}
/**
* Fetch content from PurpleAir
*
* @param {number} sensorId
* @returns {Promise<SensorData>}
*/
async function getSensorData(sensorId) {
const req = new Request(`${API_URL}/json?show=${sensorId}`);
const json = await req.loadJSON();
return {
val: json.results[0].Stats,
adj1: json.results[0].pm2_5_cf_1,
adj2: json.results[1].pm2_5_cf_1,
ts: json.results[0].LastSeen,
hum: json.results[0].humidity,
loc: json.results[0].Label,
lat: json.results[0].Lat,
lon: json.results[0].Lon,
};
}
/**
* Fetch content from PurpleAir
*
* @param {string} lat
* @param {string} lon
* @returns {Promise<GeospatialData>}
*/
async function getGeoData(lat, lon) {
const providerUrl = 'https://geocode.xyz/'
const req = new Request(`${providerUrl}${lat},${lon}?geoit=json`);
const json = await req.loadJSON();
return {
city: json.city,
state: json.state,
stateName: json.statename,
zip: json.postal,
};
}
/** @type {Array<LevelAttribute>} sorted by threshold desc. */
const LEVEL_ATTRIBUTES = [
{
threshold: 300,
label: "Hazardous",
thresholdColor: "#5E5CE6",
},
{
threshold: 200,
label: "Very Unhealthy",
thresholdColor: "#BF5AF2",
},
{
threshold: 150,
label: "Unhealthy",
thresholdColor: "#FF453A",
},
{
threshold: 100,
label: "Unhealthy for Sensitive Groups",
thresholdColor: "#FF9F0A",
},
{
threshold: 50,
label: "Moderate",
thresholdColor: "#FFD60A",
},
{
threshold: -20,
label: "Good",
thresholdColor: "#30D158",
},
];
/**
* Get the EPA adjusted PPM
*
* @param {SensorData} sensorData
* @returns {number} EPA draft adjustment for wood smoke and PurpleAir from https://cfpub.epa.gov/si/si_public_record_report.cfm?dirEntryId=349513&Lab=CEMM&simplesearch=0&showcriteria=2&sortby=pubDate&timstype=&datebeginpublishedpresented=08/25/2018
*/
function computePM(sensorData) {
const adj1 = Number.parseInt(sensorData.adj1, 10);
const adj2 = Number.parseInt(sensorData.adj2, 10);
const hum = Number.parseInt(sensorData.hum, 10);
const dataAverage = (adj1 + adj2) / 2;
return 0.52 * dataAverage - 0.085 * hum + 5.71;
}
/**
* Get AQI number from PPM reading
*
* @param {number} pm
* @returns {number|'-'}
*/
function aqiFromPM(pm) {
if (pm > 350.5) return calculateAQI(pm, 500.0, 401.0, 500.0, 350.5);
if (pm > 250.5) return calculateAQI(pm, 400.0, 301.0, 350.4, 250.5);
if (pm > 150.5) return calculateAQI(pm, 300.0, 201.0, 250.4, 150.5);
if (pm > 55.5) return calculateAQI(pm, 200.0, 151.0, 150.4, 55.5);
if (pm > 35.5) return calculateAQI(pm, 150.0, 101.0, 55.4, 35.5);
if (pm > 12.1) return calculateAQI(pm, 100.0, 51.0, 35.4, 12.1);
if (pm >= 0.0) return calculateAQI(pm, 50.0, 0.0, 12.0, 0.0);
return "-";
}
/**
* Calculate the AQI number
*
* @param {number} Cp
* @param {number} Ih
* @param {number} Il
* @param {number} BPh
* @param {number} BPl
* @returns {number}
*/
function calculateAQI(Cp, Ih, Il, BPh, BPl) {
const a = Ih - Il;
const b = BPh - BPl;
const c = Cp - BPl;
return Math.round((a / b) * c + Il);
}
/**
* Calculates the AQI level
* based on https://cfpub.epa.gov/airnow/index.cfm?action=aqibasics.aqi#unh
*
* @param {number|'-'} aqi
* @returns {LevelAttribute & { level: number }}
*/
function calculateLevel(aqi) {
const level = Number(aqi) || 0;
const {
label = "Weird",
thresholdColor = "#009900",
threshold = -Infinity,
} = LEVEL_ATTRIBUTES.find(({threshold}) => level > threshold) || {};
return {
label,
thresholdColor,
threshold,
level,
};
}
/**
* Get the AQI trend
*
* @param {{ v1: number; v3: number; }} stats
* @returns {string}
*/
function getAQITrend({v1: partLive, v3: partTime}) {
const partDelta = partTime - partLive;
if (partDelta > 5) return "arrow.down";
if (partDelta < -5) return "arrow.up";
return "arrow.left.and.right";
}
/**
* Constructs an SFSymbol from the given symbolName
*
* @param {string} symbolName
* @returns {object} SFSymbol
*/
function createSymbol(symbolName) {
const symbol = SFSymbol.named(symbolName);
symbol.applyFont(Font.systemFont(20));
return symbol;
}
async function run() {
const listWidget = new ListWidget();
listWidget.setPadding(22, 16, 12, 12);
try {
const sensorId = await getSensorId();
console.log(`Using sensor ID: ${SENSOR_ID}`);
const data = await getSensorData(sensorId);
const stats = JSON.parse(data.val);
console.log({stats});
const aqiTrend = getAQITrend(stats);
console.log({aqiTrend});
const epaPM = computePM(data);
console.log({epaPM});
const aqi = aqiFromPM(epaPM);
const level = calculateLevel(aqi);
const aqiText = aqi.toString();
console.log({aqi});
listWidget.backgroundColor = new Color('#0E1012');
const primaryColor = new Color('#FFFFFF');
const secondaryColor = new Color('#8D8D93');
const tertiaryColor = new Color('#47474A');
const quaternaryColor = new Color('#2A2A2C');
const header = listWidget.addText('Air Quality'.toUpperCase());
header.textColor = secondaryColor;
header.font = Font.semiboldSystemFont(12);
header.minimumScaleFactor = 0.50;
const wordLevel = listWidget.addText(level.label);
wordLevel.textColor = new Color(level.thresholdColor);
wordLevel.font = Font.semiboldSystemFont(20);
wordLevel.minimumScaleFactor = 0.75;
listWidget.addSpacer(4);
const scoreStack = listWidget.addStack()
const content = scoreStack.addText(aqiText);
content.textColor = primaryColor;
content.font = Font.regularSystemFont(56);
const trendSymbol = createSymbol(aqiTrend);
const trendImg = scoreStack.addImage(trendSymbol.image);
trendImg.resizable = false;
trendImg.tintColor = primaryColor;
trendImg.imageSize = new Size(30, 38);
const geoData = await getGeoData(data.lat, data.lon)
const locationText = listWidget.addText(`${geoData.city}, ${geoData.stateName}`);
locationText.textColor = tertiaryColor;
locationText.font = Font.regularSystemFont(8);
const updatedAt = new Date(data.ts * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
const widgetText = listWidget.addText(`Updated ${updatedAt}`);
widgetText.textColor = tertiaryColor;
widgetText.font = Font.regularSystemFont(8);
const purpleMapUrl = `https://www.purpleair.com/map?opt=1/i/mAQI/a10/cC5&select=${SENSOR_ID}#14/${data.lat}/${data.lon}`;
listWidget.url = purpleMapUrl;
} catch (error) {
console.log(error);
const errorWidgetText = listWidget.addText(`${error}`);
errorWidgetText.textColor = Color.red();
errorWidgetText.textOpacity = 30;
errorWidgetText.font = Font.regularSystemFont(10);
}
if (config.runsInApp) {
listWidget.presentSmall();
}
Script.setWidget(listWidget);
Script.complete();
}
await run();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.