Skip to content

Instantly share code, notes, and snippets.

@elithrar
Last active July 16, 2023 14:44
Show Gist options
  • Star 7 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save elithrar/92d43cdf03c13b45fbe208cda107c1d5 to your computer and use it in GitHub Desktop.
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/
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