Advertisement
Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- // Variables used by Scriptable.
- // These must be at the very top of the file. Do not edit.
- // icon-color: deep-purple; icon-glyph: calendar;
- /*
- ~
- Welcome to Weather Cal. Run this script to set up your widget.
- Add or remove items from the widget in the layout section below.
- You can duplicate this script to create multiple widgets. Make sure to change the name of the script each time.
- Happy scripting!
- ~
- */
- // Specify the layout of the widget items.
- const layout = `
- row
- column
- date
- space(5)
- aqi
- column(120)
- current
- space(15)
- future
- space(15)
- battery
- sunset
- space(10)
- row
- column
- scriptable
- `
- /*
- * CODE
- * Be more careful editing this section.
- * =====================================
- */
- // Names of Weather Cal elements.
- const codeFilename = "Weather Cal code"
- const gitHubUrl = "https://raw.githubusercontent.com/mzeryck/Weather-Cal/main/weather-cal-code.js"
- // Determine if the user is using iCloud.
- let files = FileManager.local()
- const iCloudInUse = files.isFileStoredIniCloud(module.filename)
- // If so, use an iCloud file manager.
- files = iCloudInUse ? FileManager.iCloud() : files
- // Determine if the Weather Cal code exists and download if needed.
- const pathToCode = files.joinPath(files.documentsDirectory(), codeFilename + ".js")
- if (!files.fileExists(pathToCode)) {
- const req = new Request(gitHubUrl)
- const codeString = await req.loadString()
- files.writeString(pathToCode, codeString)
- }
- // Import the code.
- if (iCloudInUse) { await files.downloadFileFromiCloud(pathToCode) }
- const code = importModule(codeFilename)
- const custom = {
- async scriptable(column) {
- // This script shows a random Scriptable API in a widget. The script is meant to be used with a widget configured on the Home Screen.
- // You can run the script in the app to preview the widget or you can go to the Home Screen, add a new Scriptable widget and configure the widget to run this script.
- // You can also try creating a shortcut that runs this script. Running the shortcut will show widget.
- let api = await randomAPI()
- let widget = await createWidget(api)
- if (config.runsInWidget) {
- // The script runs inside a widget, so we pass our instance of ListWidget to be shown inside the widget on the Home Screen.
- } else {
- // The script runs inside the app, so we preview the widget.
- }
- // This can speed up the execution, in particular when running the script from Shortcuts or using Siri.
- async function createWidget(api) {
- let appIcon = await loadAppIcon()
- let title = "Random Scriptable API"
- let widget = column.addStack()
- widget.layoutVertically()
- widget.cornerRadius = 20
- widget.setPadding(10, 10, 10, 10)
- // Add background gradient
- let gradient = new LinearGradient()
- gradient.locations = [0, 1]
- gradient.colors = [
- new Color("141414"),
- new Color("13233F")
- ]
- widget.backgroundGradient = gradient
- // Show app icon and title
- let titleStack = widget.addStack()
- let appIconElement = titleStack.addImage(appIcon)
- appIconElement.imageSize = new Size(15, 15)
- appIconElement.cornerRadius = 4
- titleStack.addSpacer(4)
- let titleElement = titleStack.addText(title)
- titleElement.textColor = Color.white()
- titleElement.textOpacity = 0.7
- titleElement.font = Font.mediumSystemFont(13)
- widget.addSpacer(12)
- // Show API
- let nameElement = widget.addText(api.name)
- nameElement.textColor = Color.white()
- nameElement.font = Font.boldSystemFont(18)
- widget.addSpacer(2)
- let descriptionElement = widget.addText(api.description)
- descriptionElement.minimumScaleFactor = 0.5
- descriptionElement.textColor = Color.white()
- descriptionElement.font = Font.systemFont(18)
- // UI presented in Siri ans Shortcuta is non-interactive, so we only show the footer when not running the script from Siri.
- if (!config.runsWithSiri) {
- widget.addSpacer(8)
- // Add button to open documentation
- let linkSymbol = SFSymbol.named("arrow.up.forward")
- let footerStack = widget.addStack()
- let linkStack = footerStack.addStack()
- linkStack.centerAlignContent()
- linkStack.url = api.url
- let linkElement = linkStack.addText("Read more")
- linkElement.font = Font.mediumSystemFont(13)
- linkElement.textColor = Color.blue()
- linkStack.addSpacer(3)
- let linkSymbolElement = linkStack.addImage(linkSymbol.image)
- linkSymbolElement.imageSize = new Size(11, 11)
- linkSymbolElement.tintColor = Color.blue()
- footerStack.addSpacer()
- // Add link to documentation
- let docsSymbol = SFSymbol.named("book")
- let docsElement = footerStack.addImage(docsSymbol.image)
- docsElement.imageSize = new Size(20, 20)
- docsElement.tintColor = Color.white()
- docsElement.imageOpacity = 0.5
- docsElement.url = "https://docs.scriptable.app"
- }
- }
- async function randomAPI() {
- let docs = await loadDocs()
- let apiNames = Object.keys(docs)
- let num = Math.round(Math.random() * apiNames.length)
- let apiName = apiNames[num]
- let api = docs[apiName]
- return {
- name: apiName,
- description: api["!doc"],
- url: api["!url"]
- }
- }
- async function loadDocs() {
- let url = "https://docs.scriptable.app/scriptable.json"
- let req = new Request(url)
- return await req.loadJSON()
- }
- async function loadAppIcon() {
- let url = "https://is5-ssl.mzstatic.com/image/thumb/Purple124/v4/21/1e/13/211e13de-2e74-4221-f7db-d6d2c53b4323/AppIcon-1x_U007emarketing-0-7-0-85-220.png/540x540sr.jpg"
- let req = new Request(url)
- return req.loadImage()
- }
- },
- async aqi(column) {
- "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;
- /**
- * 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} startColor
- * @property {string} endColor
- * @property {string} textColor
- * @property {string} darkStartColor
- * @property {string} darkEndColor
- * @property {string} darkTextColor
- */
- /**
- * @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 JSON from a local file
- *
- * @param {string} fileName
- * @returns {object}
- */
- function getCachedData(fileName) {
- const fileManager = FileManager.local();
- const cacheDirectory = fileManager.joinPath(fileManager.libraryDirectory(), "jsnell-aqi");
- const cacheFile = fileManager.joinPath(cacheDirectory, fileName);
- if (!fileManager.fileExists(cacheFile)) {
- return undefined;
- }
- const contents = fileManager.readString(cacheFile);
- return JSON.parse(contents);
- }
- /**
- * Wite JSON to a local file
- *
- * @param {string} fileName
- * @param {object} data
- */
- function cacheData(fileName, data) {
- const fileManager = FileManager.local();
- const cacheDirectory = fileManager.joinPath(fileManager.libraryDirectory(), "jsnell-aqi");
- const cacheFile = fileManager.joinPath(cacheDirectory, fileName);
- if (!fileManager.fileExists(cacheDirectory)) {
- fileManager.createDirectory(cacheDirectory);
- }
- const contents = JSON.stringify(data);
- fileManager.writeString(cacheFile, contents);
- }
- /**
- * Get the closest PurpleAir sensorId to the given location
- *
- * @returns {Promise<number>}
- */
- async function getSensorId() {
- if (SENSOR_ID) return SENSOR_ID;
- let fallbackSensorId = undefined;
- try {
- const cachedSensor = getCachedData("sensor.json");
- if (cachedSensor) {
- console.log({ cachedSensor });
- const { id, updatedAt } = cachedSensor;
- fallbackSensorId = id;
- // If we've fetched the location within the last 15 minutes, just return it
- if (Date.now() - updatedAt < 15 * 60 * 1000) {
- return id;
- }
- }
- /** @type {LatLon} */
- const { latitude, longitude } = await Location.current();
- const BOUND_OFFSET = 0.2;
- 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}/json?exclude=true&nwlat=${nwLat}&selat=${seLat}&nwlng=${nwLng}&selng=${seLng}`
- );
- /** @type {{ code?: number; results?: Array<Object<string, number|string>>; }} */
- const res = await req.loadJSON();
- const { results } = res;
- const sensorIdField = "ID";
- const latField = "Lat";
- const lonField = "Lon";
- const locationField = "DEVICE_LOCATIONTYPE";
- const ageField = "AGE";
- const OUTDOOR = "outside";
- let closestSensor;
- let closestDistance = Infinity;
- for (const location of results.filter((datum) => datum[locationField] === OUTDOOR && datum[ageField] < 60 * 4)) {
- const distanceFromLocation = haversine(
- { latitude, longitude },
- { latitude: location[latField], longitude: location[lonField] }
- );
- if (distanceFromLocation < closestDistance) {
- closestDistance = distanceFromLocation;
- closestSensor = location;
- }
- }
- const id = closestSensor[sensorIdField];
- cacheData("sensor.json", { id, updatedAt: Date.now() });
- return id;
- } catch (error) {
- console.log(`Could not fetch location: ${error}`);
- return fallbackSensorId;
- }
- }
- /**
- * 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 sensorCache = `sensor-${sensorId}-data.json`;
- const req = new Request(`${API_URL}/json?show=${sensorId}`);
- let json = await req.loadJSON();
- try {
- // Check that our results are what we expect
- if (json && json.results && Array.isArray(json.results) && json.results.length > 1) {
- console.log(`Sensor data looks good, will cache.`);
- const sensorData = { json, updatedAt: Date.now() }
- cacheData(sensorCache, sensorData);
- } else {
- const { json: cachedJson, updatedAt } = getCachedData(sensorCache);
- if (Date.now() - updatedAt > 2 * 60 * 60 * 1000) {
- // Bail if our data is > 2 hours old
- throw `Our cache is too old: ${updatedAt }`;
- }
- console.log(`Using cached sensor data: ${updatedAt}`);
- json = cachedJson;
- }
- 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,
- };
- } catch (error) {
- console.log(`Could not parse JSON: ${error}`);
- throw 666;
- }
- }
- /**
- * Fetch reverse geocode
- *
- * @param {string} lat
- * @param {string} lon
- * @returns {Promise<GeospatialData>}
- */
- async function getGeoData(lat, lon) {
- const latitude = Number.parseFloat(lat);
- const longitude = Number.parseFloat(lon);
- const geo = await Location.reverseGeocode(latitude, longitude);
- console.log({ geo: geo });
- return {
- neighborhood: geo[0].subLocality,
- city: geo[0].locality,
- state: geo[0].administrativeArea,
- };
- }
- /**
- * Fetch a renderable location
- *
- * @param {SensorData} data
- * @returns {Promise<String>}
- */
- async function getLocation(data) {
- try {
- if (args.widgetParameter) {
- return data.loc;
- }
- const geoData = await getGeoData(data.lat, data.lon);
- console.log({ geoData });
- if (geoData.neighborhood && geoData.city) {
- return `${geoData.neighborhood}, ${geoData.city}`;
- } else {
- return geoData.city || data.loc;
- }
- } catch (error) {
- console.log(`Could not cleanup location: ${error}`);
- return data.loc;
- }
- }
- /** @type {Array<LevelAttribute>} sorted by threshold desc. */
- const LEVEL_ATTRIBUTES = [
- {
- threshold: 300,
- label: "Hazardous",
- startColor: "76205d",
- endColor: "521541",
- textColor: "f0f0f0",
- darkStartColor: "333333",
- darkEndColor: "000000",
- darkTextColor: "ce4ec5",
- },
- {
- threshold: 200,
- label: "Very Unhealthy",
- startColor: "9c2424",
- endColor: "661414",
- textColor: "f0f0f0",
- darkStartColor: "333333",
- darkEndColor: "000000",
- darkTextColor: "f33939",
- },
- {
- threshold: 150,
- label: "Unhealthy",
- startColor: "da5340",
- endColor: "bc2f26",
- textColor: "eaeaea",
- darkStartColor: "333333",
- darkEndColor: "000000",
- darkTextColor: "f16745",
- },
- {
- threshold: 100,
- label: "Unhealthy for Sensitive Groups",
- startColor: "f5ba2a",
- endColor: "d3781c",
- textColor: "1f1f1f",
- darkStartColor: "333333",
- darkEndColor: "000000",
- darkTextColor: "f7a021",
- },
- {
- threshold: 50,
- label: "Moderate",
- startColor: "f2e269",
- endColor: "dfb743",
- textColor: "1f1f1f",
- darkStartColor: "333333",
- darkEndColor: "000000",
- darkTextColor: "f2e269",
- },
- {
- threshold: -20,
- label: "Good",
- startColor: "8fec74",
- endColor: "77c853",
- textColor: "1f1f1f",
- darkStartColor: "333333",
- darkEndColor: "000000",
- darkTextColor: "6de46d",
- },
- ];
- /**
- * Get the EPA adjusted PPM
- *
- * @param {SensorData} sensorData
- * @returns {number} EPA adjustment for wood smoke and PurpleAir from slide 8 of 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;
- console.log(`PM2.5 number is ${dataAverage}.`)
- if (dataAverage < 250) {
- console.log(`Using EPA calculation.`)
- return 0.52 * dataAverage - 0.085 * hum + 5.71;
- } else {
- console.log(`Using AQANDU calculation.`)
- return .0778 * dataAverage + 2.65
- }
- }
- /**
- * 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",
- startColor = "white",
- endColor = "white",
- textColor = "black",
- darkStartColor = "009900",
- darkEndColor = "007700",
- darkTextColor = "000000",
- threshold = -Infinity,
- } = LEVEL_ATTRIBUTES.find(({ threshold }) => level > threshold) || {};
- return {
- label,
- startColor,
- endColor,
- textColor,
- darkStartColor,
- darkEndColor,
- darkTextColor,
- 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(15));
- return symbol;
- }
- async function run() {
- const listWidget = column.addStack()
- listWidget.layoutVertically()
- listWidget.cornerRadius = 20
- listWidget.setPadding(10, 10, 10, 10);
- listWidget.setPadding(10, 15, 10, 10);
- try {
- const sensorId = await getSensorId();
- if (!sensorId) {
- throw "Please specify a location for this widget.";
- }
- console.log(`Using sensor ID: ${sensorId}`);
- 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 });
- const sensorLocation = await getLocation(data)
- console.log({ sensorLocation });
- const startColor = Color.dynamic(new Color(level.startColor), new Color(level.darkStartColor));
- const endColor = Color.dynamic(new Color(level.endColor), new Color(level.darkEndColor));
- const textColor = Color.dynamic(new Color(level.textColor), new Color(level.darkTextColor));
- const gradient = new LinearGradient();
- gradient.colors = [startColor, endColor];
- gradient.locations = [0.0, 1];
- console.log({ gradient });
- listWidget.backgroundGradient = gradient;
- const header = listWidget.addText('Air Quality'.toUpperCase());
- header.textColor = textColor;
- header.font = Font.regularSystemFont(11);
- header.minimumScaleFactor = 0.50;
- const wordLevel = listWidget.addText(level.label);
- wordLevel.textColor = textColor;
- wordLevel.font = Font.semiboldSystemFont(25);
- wordLevel.minimumScaleFactor = 0.3;
- listWidget.addSpacer(5);
- const scoreStack = listWidget.addStack();
- const content = scoreStack.addText(aqiText);
- content.textColor = textColor;
- content.font = Font.semiboldSystemFont(30);
- const trendSymbol = createSymbol(aqiTrend);
- const trendImg = scoreStack.addImage(trendSymbol.image);
- trendImg.resizable = false;
- trendImg.tintColor = textColor;
- trendImg.imageSize = new Size(28, 30);
- listWidget.addSpacer(10);
- const locationText = listWidget.addText(sensorLocation);
- locationText.textColor = textColor;
- locationText.font = Font.regularSystemFont(14);
- locationText.minimumScaleFactor = 0.5;
- listWidget.addSpacer(2);
- const updatedAt = new Date(data.ts * 1000).toLocaleTimeString([], {
- hour: "numeric",
- minute: "2-digit",
- });
- const widgetText = listWidget.addText(`Updated ${updatedAt}`);
- widgetText.textColor = textColor;
- widgetText.font = Font.regularSystemFont(9);
- widgetText.minimumScaleFactor = 0.6;
- const purpleMapUrl = `https://www.purpleair.com/map?opt=1/i/mAQI/a10/cC5&select=${sensorId}#14/${data.lat}/${data.lon}`;
- listWidget.url = purpleMapUrl;
- } catch (error) {
- if (error === 666) {
- // Handle JSON parsing errors with a custom error layout
- listWidget.background = new Color('999999');
- const header = listWidget.addText('Error'.toUpperCase());
- header.textColor = new Color('000000');
- header.font = Font.regularSystemFont(11);
- header.minimumScaleFactor = 0.50;
- listWidget.addSpacer(15);
- const wordLevel = listWidget.addText(`Couldn't connect to the server.`);
- wordLevel.textColor = new Color ('000000');
- wordLevel.font = Font.semiboldSystemFont(15);
- wordLevel.minimumScaleFactor = 0.3;
- } else {
- console.log(`Could not render widget: ${error}`);
- const errorWidgetText = listWidget.addText(`${error}`);
- errorWidgetText.textColor = Color.red();
- errorWidgetText.textOpacity = 30;
- errorWidgetText.font = Font.regularSystemFont(10);
- }
- }
- if (config.runsInApp) {
- }
- }
- await run();
- },
- }
- // Run the initial setup or settings menu.
- let preview
- if (config.runsInApp) {
- preview = await code.runSetup(Script.name(), iCloudInUse, codeFilename, gitHubUrl)
- if (!preview) return
- }
- // Set up the widget.
- const widget = await code.createWidget(layout, Script.name(), iCloudInUse, custom)
- widget.setPadding(0,0,0,0)
- Script.setWidget(widget)
- // If we're in app, display the preview.
- if (config.runsInApp) {
- if (preview == "small") { widget.presentSmall() }
- else if (preview == "medium") { widget.presentMedium() }
- else { widget.presentLarge() }
- }
- Script.complete()
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement