Skip to content

Instantly share code, notes, and snippets.

@jasonsnell
Last active October 7, 2020 22:42
Show Gist options
  • Star 14 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save jasonsnell/4b458e2775e11ff7dd8b21dd26aa504e to your computer and use it in GitHub Desktop.
Save jasonsnell/4b458e2775e11ff7dd8b21dd26aa504e to your computer and use it in GitHub Desktop.
Purple AQI Widget
I've moved this to a proper GitHub project.
Check it out at:
https://github.com/jasonsnell/PurpleAir-AQI-Scriptable-Widget
@areohbe
Copy link

areohbe commented Sep 15, 2020

Thanks @jasonsnell from a fellow Bay Area resident. Made a few tweaks to mine if you're interested. Moved all the styling attributes into a single "config" object that are set using getLevelAttributes. I wanted to add a gradient background so this made it easier to tweak and quickly test.

// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: deep-brown; icon-glyph: magic;
const API_URL = "https://www.purpleair.com/json?show=";
// 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 || "4896";

async function getSensorData(url, id) {
  let req = new Request(`${url}${id}`);
  let json = await req.loadJSON();

  return {
    val: json.results[0].PM2_5Value,
    ts: json.results[0].LastSeen,
    loc: json.results[0].Label,
  };
}

// Widget attributes: AQI level threshold, text label, gradient start and end colors
const levelAttributes = [
  {
    threshold: 300,
    label: "Hazardous",
    startColor: "FF3DE0",
    endColor: "D600B2",
  },
  {
    threshold: 200,
    label: "Very Unhealthy",
    startColor: "CD3DFF",
    endColor: "9D00D6",
  },
  {
    threshold: 150,
    label: "Unhealthy",
    startColor: "FF3D3D",
    endColor: "D60000",
  },
  {
    threshold: 100,
    label: "Unhealthy (S.G.)",
    startColor: "FFA63D",
    endColor: "D67200",
  },
  {
    threshold: 50,
    label: "Moderate",
    startColor: "FFA63D",
    endColor: "D67200",
  },
  {
    threshold: 0,
    label: "Healthy",
    startColor: "3DFF73",
    endColor: "00D63D",
  },
];

// Get level attributes for AQI
const getLevelAttributes = (level, attributes) =>
  attributes
    .filter((c) => level > c.threshold)
    .sort((a, b) => b.threshold - a.threshold)[0];

// Calculates the AQI level based on
// https://cfpub.epa.gov/airnow/index.cfm?action=aqibasics.aqi#unh
function calculateLevel(aqi) {
  let res = {
    level: "OK",
    label: "fine",
    startColor: "white",
    endColor: "white",
  };

  let level = parseInt(aqi, 10) || 0;

  // Set attributes
  res = getLevelAttributes(level, levelAttributes);
  // Set level
  res.level = level;
  return res;
}

//Function to get AQI number from PPM reading
function aqiFromPM(pm) {
  if (pm > 350.5) {
    return calcAQI(pm, 500.0, 401.0, 500.0, 350.5);
  } else if (pm > 250.5) {
    return calcAQI(pm, 400.0, 301.0, 350.4, 250.5);
  } else if (pm > 150.5) {
    return calcAQI(pm, 300.0, 201.0, 250.4, 150.5);
  } else if (pm > 55.5) {
    return calcAQI(pm, 200.0, 151.0, 150.4, 55.5);
  } else if (pm > 35.5) {
    return calcAQI(pm, 150.0, 101.0, 55.4, 35.5);
  } else if (pm > 12.1) {
    return calcAQI(pm, 100.0, 51.0, 35.4, 12.1);
  } else if (pm >= 0.0) {
    return calcAQI(pm, 50.0, 0.0, 12.0, 0.0);
  } else {
    return "-";
  }
}

//Function that actually calculates the AQI number
function calcAQI(Cp, Ih, Il, BPh, BPl) {
  let a = Ih - Il;
  let b = BPh - BPl;
  let c = Cp - BPl;
  return Math.round((a / b) * c + Il);
}

async function run() {
  let wg = new ListWidget();

  try {
    console.log(`Using sensor ID: ${SENSOR_ID}`);
    let data = await getSensorData(API_URL, SENSOR_ID);
    console.log(data);

    let header = wg.addText("Air Quality");
    header.textSize = 15;
    header.textColor = Color.black();

    let aqi = aqiFromPM(data.val);
    let level = calculateLevel(aqi);
    let aqitext = aqi.toString();
    console.log(aqi);
    console.log(level.level);
    let startColor = new Color(level.startColor);
    let endColor = new Color(level.endColor);
    let gradient = new LinearGradient();
    gradient.colors = [startColor, endColor];
    gradient.locations = [0.0, 1];
    console.log(gradient);

    wg.backgroundGradient = gradient;

    let content = wg.addText(aqitext);
    content.textSize = 50;
    content.textColor = Color.black();

    let wordLevel = wg.addText(level.label);
    wordLevel.textSize = 15;
    wordLevel.textColor = Color.black();

    let id = wg.addText(data.loc);
    id.textSize = 10;
    id.textColor = Color.black();

    let updatedAt = new Date(data.ts * 1000).toLocaleTimeString("en-US", {
      timeZone: "PST",
    });
    let ts = wg.addText(`Updated ${updatedAt}`);
    ts.textSize = 10;
    ts.textColor = Color.black();
  } catch (e) {
    console.log(e);
    let err = wg.addText(`error: ${e}`);
    err.textSize = 10;
    err.textColor = Color.red();
    err.textOpacity = 30;
  }

  Script.setWidget(wg);
  Script.complete();
}
await run();

@jasonsnell
Copy link
Author

fantastic! I've updated my script above to integrate your changes.

@areohbe
Copy link

areohbe commented Sep 18, 2020

👍 Glad you caught my typos!

@areohbe
Copy link

areohbe commented Sep 18, 2020

@yanlesin My javascript knowledge is pretty limited, but there's only a single network request happening here, right? Seems like this should be fine as is?

@yanlesin
Copy link

yanlesin commented Sep 18, 2020

Thanks Rob @areohbe - you are correct - single request... That is why I deleted comment...

@jasonsnell
Copy link
Author

Hey @areohbe @yanlesin looks like the Springboard widget refresher runs pretty sporadically, too, so it doesn't do anything too hard to the server.

@jasonsnell
Copy link
Author

👍 Glad you caught my typos!

Ha, not sure I noticed any! But I did change the gradient colors to be closer to the "official" ones and added in the text color so you can have black text for lighter colors and white text for darker colors.

@lickel
Copy link

lickel commented Sep 20, 2020

Hi Jason, if you're interested I cleaned up the indenting of the file (mostly tabs -> spaces), and moved the trends and EPA adjustment stuff into functions. I also simplified toLocaleTimeString() to use your current locale settings where appropriate.

It should otherwise be functionally identical, but easier to read 🙂.

// widget code by Jason Snell <jsnell@sixcolors.com>
// based on code by Matt Silverlock
// gradient routine contributed by Rob Silverii
const API_URL = "https://www.purpleair.com/json?show=";

// 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 || "34663";

// Fetch content from PurpleAir
async function getSensorData(url, id) {
  let req = new Request(`${url}${id}`);
  let 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
  };
}

// Widget attributes: AQI level threshold, text label, gradient start and end colors, text color
const levelAttributes = [
  {
    threshold: 300,
    label: "Hazardous",
    startColor: "9e2043",
    endColor: "7e0023",
    textColor: "ffffff",
  },
  {
    threshold: 200,
    label: "Very Unhealthy",
    startColor: "8f3f97",
    endColor: "6f1f77",
    textColor: "ffffff",
  },
  {
    threshold: 150,
    label: "Unhealthy",
    startColor: "FF3D3D",
    endColor: "D60000",
    textColor: "000000",
  },
  {
    threshold: 100,
    label: "Unhealthy (S.G.)",
    startColor: "FFA63D",
    endColor: "D67200",
    textColor: "000000",
  },
  {
    threshold: 50,
    label: "Moderate",
    startColor: "ffff00",
    endColor: "cccc00",
    textColor: "000000",
  },
  {
    threshold: 0,
    label: "Good",
    startColor: "00e400",
    endColor: "00bb00",
    textColor: "000000",
  },
];

// Get level attributes for AQI
function getLevelAttributes(level, attributes) {
  let applicableAttributes = attributes
    .filter((c) => level > c.threshold)
    .sort((a, b) => b.threshold - a.threshold);
  return applicableAttributes[0];
}

// Function to get the EPA adjusted PPM
function computePM(data) {
  let adj1 = parseInt(data.adj1, 10);
  let adj2 = parseInt(data.adj2, 10);
  let hum = parseInt(data.hum, 10);
  let dataAverage = ((adj1 + adj2)/2);

  // Apply 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

  return ((0.524 * dataAverage) - (.0085 * hum) + 5.71);
}

// Function to get AQI number from PPM reading
function aqiFromPM(pm) {
  if (pm > 350.5) {
    return calcAQI(pm, 500.0, 401.0, 500.0, 350.5);
  } else if (pm > 250.5) {
    return calcAQI(pm, 400.0, 301.0, 350.4, 250.5);
  } else if (pm > 150.5) {
    return calcAQI(pm, 300.0, 201.0, 250.4, 150.5);
  } else if (pm > 55.5) {
    return calcAQI(pm, 200.0, 151.0, 150.4, 55.5);
  } else if (pm > 35.5) {
    return calcAQI(pm, 150.0, 101.0, 55.4, 35.5);
  } else if (pm > 12.1) {
    return calcAQI(pm, 100.0, 51.0, 35.4, 12.1);
  } else if (pm >= 0.0) {
    return calcAQI(pm, 50.0, 0.0, 12.0, 0.0);
  } else {
    return "-";
  }
}

// Function that actually calculates the AQI number
function calcAQI(Cp, Ih, Il, BPh, BPl) {
  let a = (Ih - Il);
  let b = (BPh - BPl);
  let 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
function calculateLevel(aqi) {
  let res = {
    level: "OK",
    label: "fine",
    startColor: "white",
    endColor: "white",
  };

  let level = parseInt(aqi, 10) || 0;

  // Set attributes
  res = getLevelAttributes(level, levelAttributes);
  // Set level
  res.level = level;
  return res;
}

// Function to get the AQI trends suffix
function trendsFromStats(stats) {
  let partLive = parseInt(stats.v1, 10);
  let partTime = parseInt(stats.v2, 10);
  let partDelta = partTime - partLive;

  if (partDelta > 5) {
    theTrend = " Improving";
  } else if (partDelta < -5) {
    theTrend = " Worsening";
  } else {
    theTrend = "";
  }
  return theTrend;
}

async function run() {
  let wg = new ListWidget();
  wg.setPadding(20, 15, 10, 10);

  try {
    console.log(`Using sensor ID: ${SENSOR_ID}`);

    let data = await getSensorData(API_URL, SENSOR_ID);
    let stats = JSON.parse(data.val);
    console.log(stats);

    let theTrend = trendsFromStats(stats);
    console.log(theTrend);

    let epaPM = computePM(data);
    console.log(epaPM);

    let aqi = aqiFromPM(epaPM);
    let level = calculateLevel(aqi);
    let aqiText = aqi.toString();
    console.log(aqi);
    console.log(level.level);

    let startColor = new Color(level.startColor);
    let endColor = new Color(level.endColor);
    let textColor = new Color(level.textColor);
    let gradient = new LinearGradient();
    gradient.colors = [startColor, endColor];
    gradient.locations = [0.0, 1];
    console.log(gradient);

    wg.backgroundGradient = gradient;

    let header = wg.addText("AQI" + theTrend);
    header.textColor = textColor;
    header.font = Font.regularSystemFont(15);

    let content = wg.addText(aqiText);
    content.textColor = textColor;
    content.font = Font.semiboldRoundedSystemFont(45);

    let wordLevel = wg.addText(level.label);
    wordLevel.textColor = textColor;
    wordLevel.font = Font.boldSystemFont(15);

    wg.addSpacer(10);

    let location = wg.addText(data.loc);
    location.textColor = textColor;
    location.font = Font.mediumSystemFont(12);

    let updatedAt = new Date(data.ts * 1000)
      .toLocaleTimeString([], { hour: '2-digit', minute: '2-digit'});
    let ts = wg.addText(`Updated ${updatedAt}`);
    ts.textColor = textColor;
    ts.font = Font.lightSystemFont(10);

    wg.addSpacer(10);

    let purpleMap = 'https://www.purpleair.com/map?opt=1/i/mAQI/a10/cC0&select=' + SENSOR_ID + '#14/' + data.lat + '/' + data.lon;
    wg.url = purpleMap;
  } catch (e) {
    console.log(e);

    let err = wg.addText(`${e}`);
    err.textColor = Color.red();
    err.textOpacity = 30;
    err.font = Font.regularSystemFont(10);
  }

  if (config.runsInApp) {
    wg.presentSmall();
  }

  Script.setWidget(wg);
  Script.complete();
}

await run();

@jasonsnell
Copy link
Author

jasonsnell commented Sep 22, 2020 via email

@jasonsnell
Copy link
Author

geez, should I just make this a GitHub project now? Thanks Adam!

@jasonsnell
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment