Last active
July 16, 2023 14:44
-
-
Save elithrar/92d43cdf03c13b45fbe208cda107c1d5 to your computer and use it in GitHub Desktop.
A Scriptable powered iOS 14 widget (https://docs.scriptable.app/) using JavaScriptCore to get the current AQI from a PurpleNow sensor: https://fire.airnow.gov/
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const API_URL = "https://www.purpleair.com/json?show="; | |
// Params: lat, lng, zoom | |
const MAP_URL = "https://fire.airnow.gov/" | |
// const CACHE_FILE = "aqi_data.json" | |
// 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. | |
let SENSOR_ID = args.widgetParameter || "19066" | |
const HEADER_COLOR = "#222222" | |
const TEXT_SIZE = { | |
aqi: 50, | |
title: 15, | |
meta: 10 | |
} | |
// The PM2.5 concentration levels and AQI breakpoints, used to | |
// calculate the resultant AQI. | |
const PM2_5BREAKPOINTS = { | |
"Good":{conc: [0.0, 12.0], aqi:[0, 50], color: "#48D086 "}, | |
"Moderate":{conc:[12.1, 35.4], aqi:[51, 100], color: "#F3CF2C"}, | |
"Unhealthy for Sensitive Groups":{conc:[35.5, 55.4], aqi:[101, 150], color: "#F59748"}, | |
"Unhealthy":{conc:[55.5, 150.4], aqi:[151, 200], color: "#F45252"}, | |
"Very Unhealthy":{conc:[150.5, 250.4], aqi:[201, 300], color: "#A373DF"}, | |
"Hazardous":{conc:[250.5, 500.4], aqi:[301, 500], color: "#B44868"}, | |
} | |
// Fetch the PurpleAir sensor data for a given sensor ID. | |
async function getSensorData(url, id) { | |
let req = new Request(`${url}${id}`) | |
let json = await req.loadJSON() | |
return { | |
"val": json.results[0].PM2_5Value || 0, | |
"lat": json.results[0].Lat, | |
"long": json.results[0].Lon, | |
"location_type": json.results[0].DEVICE_LOCATIONTYPE || "", | |
"temp_f": json.results[0].temp_f || "-", | |
"ts": json.results[0].LastSeen | |
} | |
} | |
// Get the sub-locality (e.g. neighborhood) or locality (city, town) | |
// of the given latitude & longitude. | |
// | |
// Useful for getting the human-readable name of a sensor's location. | |
async function getLocation(lat, long) { | |
let loc = await Location.reverseGeocode(lat, long) | |
return { | |
name: loc[0].subLocality || loc[0].locality, | |
data: loc | |
} | |
} | |
// Calculates the AQI level based on | |
// https://cfpub.epa.gov/airnow/index.cfm?action=aqibasics.aqi#unh | |
function calculateLevel(input) { | |
let res = { | |
level: "Unknown", | |
aqi: "0", | |
color: "#dddddd" | |
} | |
let conc = parseFloat(input).toFixed(1) | |
// Apply AQ&U correction factor | |
// PM2.5 (µg/m³) = 0.778 x PA + 2.65 | |
conc = 0.778 * conc + 2.65 | |
for (let [key, val] of Object.entries(PM2_5BREAKPOINTS)) { | |
if (conc >= val.conc[0] && conc <= val.conc[1]) { | |
console.log(key) | |
// AQI = (aqihi-aqilo)/(conchi-conclo) * (conc - conclo) + aqilo | |
let x = (val.aqi[1] - val.aqi[0]) | |
let y = (val.conc[1] - val.conc[0]) | |
let z = (conc - val.conc[0]) | |
res.aqi = Number((x/y) * z + val.aqi[0]).toFixed(0) | |
res.level = key | |
res.color = val.color | |
} | |
} | |
return res | |
} | |
async function run() { | |
let wg = new ListWidget() | |
let header = wg.addText("PM2.5 AQI") | |
header.font = Font.mediumSystemFont(TEXT_SIZE.title) | |
header.textColor = new Color(HEADER_COLOR) | |
// Loading/pending/stale state | |
wg.backgroundColor = new Color("#bbbbbb") | |
try { | |
console.log(`Using sensor ID: ${SENSOR_ID}`) | |
let data = await getSensorData(API_URL, SENSOR_ID) | |
console.log(data) | |
let loc = await getLocation(data.lat, data.long) | |
console.log(loc.name) | |
// wg.url = `${MAP_URL}?lat=${data.lat}&lng=${data.long}&zoom=12` | |
let res = calculateLevel(data.val) | |
console.log(res.aqi) | |
wg.backgroundColor = new Color(res.color) | |
let content = wg.addText(res.aqi.toString()) | |
content.font = Font.mediumRoundedSystemFont(TEXT_SIZE.aqi) | |
content.textColor = Color.black() | |
wg.addSpacer() | |
let locality = wg.addText(loc.name) | |
locality.font = Font.regularSystemFont(TEXT_SIZE.meta) | |
locality.textOpacity = 50 | |
locality.textColor = Color.black() | |
let temp = wg.addText(`${data.temp_f}F (${data.location_type})`) | |
temp.font = Font.regularSystemFont(TEXT_SIZE.meta) | |
temp.textOpacity = 50 | |
temp.textColor = Color.black() | |
let updatedAt = new Date(data.ts*1000).toLocaleTimeString("en-US", { timeZone: "PST" }) | |
console.log(updatedAt) | |
let ts = wg.addText(`Updated at ${updatedAt}`) | |
ts.font = Font.regularSystemFont(TEXT_SIZE.meta) | |
ts.textOpacity = 50 | |
ts.textColor = Color.black() | |
let id = wg.addText(`Sensor ID: ${SENSOR_ID}`) | |
id.font = Font.regularSystemFont(TEXT_SIZE.meta) | |
id.textOpacity = 50 | |
id.textColor = Color.black() | |
} catch (e) { | |
console.log(e) | |
// Don't overwrite existing content | |
// (there is not an API to inspect the existing widget content) | |
// let err = wg.addText(`error: ${e}`) | |
// err.textSize = TEXT_SIZE.meta | |
// err.textColor = Color.red() | |
// err.textOpacity = 30 | |
} finally { | |
Script.setWidget(wg) | |
Script.complete() | |
} | |
} | |
await run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment