Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
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

This comment has been minimized.

Copy link

@areohbe 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

This comment has been minimized.

Copy link
Owner Author

@jasonsnell jasonsnell commented Sep 17, 2020

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

@areohbe

This comment has been minimized.

Copy link

@areohbe areohbe commented Sep 18, 2020

👍 Glad you caught my typos!

@areohbe

This comment has been minimized.

Copy link

@areohbe 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

This comment has been minimized.

Copy link

@yanlesin yanlesin commented Sep 18, 2020

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

@jasonsnell

This comment has been minimized.

Copy link
Owner Author

@jasonsnell jasonsnell commented Sep 18, 2020

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

This comment has been minimized.

Copy link
Owner Author

@jasonsnell jasonsnell commented Sep 18, 2020

👍 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

This comment has been minimized.

Copy link

@lickel 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

This comment has been minimized.

Copy link
Owner Author

@jasonsnell jasonsnell commented Sep 22, 2020

@jasonsnell

This comment has been minimized.

Copy link
Owner Author

@jasonsnell jasonsnell commented Sep 22, 2020

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

@jasonsnell

This comment has been minimized.

Copy link
Owner Author

@jasonsnell jasonsnell commented Sep 22, 2020

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.