Last active
April 20, 2024 01:12
-
-
Save alexberkowitz/3abd07ecd81a4110f6b4670eef95e02a to your computer and use it in GitHub Desktop.
Tomorrow.io Widget for Scriptable
This file contains hidden or 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
// Variables used by Scriptable. | |
// These must be at the very top of the file. Do not edit. | |
// icon-color: cyan; icon-glyph: sun; | |
// Variables used by Scriptable. | |
// These must be at the very top of the file. Do not edit. | |
// icon-color: cyan; icon-glyph: sun; | |
/****************************************************************************** | |
* Info | |
*****************************************************************************/ | |
// This script displays the current weather conditions from Tomorrow.io | |
// | |
// THIS SCRIPT IS DESIGNED TO RUN AS A MEDIUM WIDGET!!! | |
// | |
// Check the configuration below to modify appearance & functionality | |
// | |
// NOTE: This script uses the Cache script (https://gist.github.com/alexberkowitz/70c34626b073ff4131dbf33af0e39b76) | |
// Make sure to add the Cache script in Scriptable as well! | |
/****************************************************************************** | |
/* Constants and Configurations | |
/* DON'T SKIP THESE!!! | |
*****************************************************************************/ | |
// Your Tomorrow.io API token | |
const API_TOKEN = 'YOUR_TOMORROW_API_KEY'; | |
// Either 'imperial' or 'metric' | |
const UNITS = 'imperial'; | |
// Number of hours ahead to get data for | |
const HOURS_AHEAD = 6; | |
// Colors | |
const COLORS = { | |
bg: '#1f1f1f', | |
hourlyBg: '#2c2c2c', | |
text: '#eeeeee', | |
locationText: '#757575', | |
timeText: '#757575', | |
probabilityText: '#0b71e4' | |
}; | |
// Percent probability of precipitation that must be reached in order to display that data | |
const PRECIPITATION_THRESHOLD = 10; | |
// You can set a location here or leave blank to enable automatic location detection. | |
// Location must be an array of [latitude, longitude] | |
// Example: const LOCATION = [41.871,-87.629]; | |
const LOCATION = null; | |
/****************************************************************************** | |
/* Calculated and Constant Values | |
/* DON'T CHANGE THESE! | |
*****************************************************************************/ | |
// Weather conditions mapping | |
const CONDITIONS = { | |
"0": {name: "Unknown", iconName: "unknown", hasNightIcon: false}, | |
"1000": {name: "Clear, Sunny", iconName: "clear", hasNightIcon: true}, | |
"1100": {name: "Mostly Clear", iconName: "mostly_clear", hasNightIcon: true}, | |
"1101": {name: "Partly Cloudy", iconName: "partly_cloudy", hasNightIcon: true}, | |
"1102": {name: "Mostly Cloudy", iconName: "mostly_cloudy", hasNightIcon: true}, | |
"1001": {name: "Cloudy", iconName: "cloudy", hasNightIcon: false}, | |
"2000": {name: "Fog", iconName: "fog", hasNightIcon: false}, | |
"2100": {name: "Light Fog", iconName: "fog_light", hasNightIcon: false}, | |
"4000": {name: "Drizzle", iconName: "drizzle", hasNightIcon: false}, | |
"4001": {name: "Rain", iconName: "rain", hasNightIcon: false}, | |
"4200": {name: "Light Rain", iconName: "rain_light", hasNightIcon: false}, | |
"4201": {name: "Heavy Rain", iconName: "rain_heavy", hasNightIcon: false}, | |
"5000": {name: "Snow", iconName: "snow", hasNightIcon: false}, | |
"5001": {name: "Flurries", iconName: "flurries", hasNightIcon: false}, | |
"5100": {name: "Light Snow", iconName: "snow_light", hasNightIcon: false}, | |
"5101": {name: "Heavy Snow", iconName: "snow_heavy", hasNightIcon: false}, | |
"6000": {name: "Freezing Drizzle", iconName: "freezing_rain_drizzle", hasNightIcon: false}, | |
"6001": {name: "Freezing Rain", iconName: "freezing_rain", hasNightIcon: false}, | |
"6200": {name: "Light Freezing Rain", iconName: "freezing_rain_light", hasNightIcon: false}, | |
"6201": {name: "Heavy Freezing Rain", iconName: "freezing_rain_heavy", hasNightIcon: false}, | |
"7000": {name: "Ice Pellets", iconName: "ice_pellets", hasNightIcon: false}, | |
"7101": {name: "Heavy Ice Pellets", iconName: "ice_pellets_heavy", hasNightIcon: false}, | |
"7102": {name: "Light Ice Pellets", iconName: "ice_pellets_light", hasNightIcon: false}, | |
"8000": {name: "Thunderstorm", iconName: "tstorm", hasNightIcon: false} | |
}; | |
// Padding of each section | |
const PADDING_HORIZ = 12; | |
const PADDING_VERT = 6; | |
/****************************************************************************** | |
* Initial Setups | |
*****************************************************************************/ | |
// Import and setup Cache | |
const Cache = importModule('Cache'); | |
const cache = new Cache('TomorrowLocation'); | |
// Get current location | |
const currentLocation = await getCurrentLocation(); | |
// Fetch data | |
const weatherData = await fetchWeatherData(currentLocation); | |
const formattedWeatherData = await formatWeatherData(weatherData); | |
// Create widget | |
const widget = await createWidget(formattedWeatherData, currentLocation); | |
Script.setWidget(widget); | |
widget.presentMedium(); // Used for testing purposes only | |
Script.complete(); | |
/****************************************************************************** | |
* Main Functions (Widget and Data-Fetching) | |
*****************************************************************************/ | |
/** | |
* Main widget function. | |
* | |
* @param {} data The data for the widget to display | |
*/ | |
async function createWidget(weatherData, currentLocationData) { | |
const currentData = weatherData.current; | |
const todayData = weatherData.today; | |
const hourlyData = weatherData.hourly; | |
//-- Initialize the widget --\\ | |
const widget = new ListWidget(); | |
widget.backgroundColor = new Color(COLORS.bg); | |
widget.setPadding(0, 0, 0, 0); | |
// The free API key for Tomorrow.io is limited to 25 requests per hour, so we limit our widget similarly | |
let nextRefresh = Date.now() + 60000*(60/25); | |
widget.refreshAfterDate = new Date(nextRefresh); | |
//-- Main Content Container --\\ | |
const contentStack = widget.addStack(); | |
contentStack.layoutVertically(); | |
contentStack.url = "climacell://"; // Open Tomorrow.io app when tapped | |
contentStack.setPadding(PADDING_VERT, 0, 0, 0); | |
//-- Weather Info --\\ | |
if( !!currentData ){ // Error response handling | |
//----- Start locationStack | |
const locationStack = contentStack.addStack(); | |
locationStack.layoutHorizontally(); | |
locationStack.spacing = 4; | |
locationStack.setPadding(0, PADDING_HORIZ, 0, PADDING_HORIZ); | |
if( !LOCATION ){ | |
const locationIconSymbol = SFSymbol.named("location.fill"); | |
const locationIcon = locationStack.addImage(locationIconSymbol.image); | |
locationIcon.imageSize = new Size(8, 8); | |
locationIcon.tintColor = new Color(COLORS.locationText); | |
} | |
const currentLocation = locationStack.addText(currentLocationData.name); | |
currentLocation.font = Font.systemFont(8); | |
currentLocation.textColor = new Color(COLORS.locationText); | |
//----- End locationStack | |
//----- Start currentForecastStack | |
const currentForecastStack = contentStack.addStack(); | |
currentForecastStack.layoutHorizontally(); | |
currentForecastStack.centerAlignContent(); | |
currentForecastStack.setPadding(0, PADDING_HORIZ, 0, PADDING_HORIZ); | |
//----- Start currentForecastLeftStack | |
const currentForecastLeftStack = currentForecastStack.addStack(); | |
currentForecastLeftStack.layoutVertically(); | |
//----- Start currentInfoStack | |
const currentInfoStack = currentForecastLeftStack.addStack(); | |
currentInfoStack.layoutHorizontally(); | |
currentInfoStack.spacing = 8; | |
//----- Start currentTempStack | |
const currentTempStack = currentInfoStack.addStack(); | |
currentTempStack.layoutVertically(); | |
currentTempStack.addSpacer(); | |
//----- Start currentTempTextContainerStack | |
// This acts as a line height for the temp text | |
const currentTempSize = 54; | |
const currentTempTextContainerStack = currentTempStack.addStack(); | |
currentTempTextContainerStack.size = new Size(0, currentTempSize); | |
currentTempTextContainerStack.centerAlignContent(); | |
// Temp | |
const currentTemp = currentTempTextContainerStack.addText(formatTemperature(currentData.values.temperature)); | |
currentTemp.font = Font.boldSystemFont(currentTempSize); | |
currentTemp.textColor = new Color(COLORS.text); | |
//----- End currentTempTextContainerStack | |
currentTempStack.addSpacer(); | |
//----- End currentTempStack | |
//----- Start tempAndConditionsStack | |
const tempAndConditionsStack = currentInfoStack.addStack(); | |
tempAndConditionsStack.layoutVertically(); | |
tempAndConditionsStack.centerAlignContent(); | |
tempAndConditionsStack.spacing = 4; | |
tempAndConditionsStack.addSpacer(); | |
// Conditions | |
const currentConditions = tempAndConditionsStack.addText(CONDITIONS[currentData.values.weatherCode].name); | |
currentConditions.font = Font.boldSystemFont(12); | |
currentConditions.centerAlignText(); | |
currentConditions.textColor = new Color(COLORS.text); | |
// Feels Like | |
const feelsLikeTemp = tempAndConditionsStack.addText(`Feels like ${formatTemperature(currentData.values.temperatureApparent)}`); | |
feelsLikeTemp.font = Font.systemFont(12); | |
feelsLikeTemp.textColor = new Color(COLORS.text); | |
// High and Low Temp | |
const hiLoTemp = tempAndConditionsStack.addText(`L ${formatTemperature(todayData.values.temperatureMin)} / H ${formatTemperature(todayData.values.temperatureMax)}`); | |
hiLoTemp.font = Font.systemFont(12); | |
hiLoTemp.textColor = new Color(COLORS.text); | |
tempAndConditionsStack.addSpacer(); | |
//----- End tempAndConditionsStack | |
//----- End currentInfoStack | |
//----- End currentForecastLeftStack | |
currentForecastStack.addSpacer(); | |
//----- Start currentForecastRightStack | |
const currentForecastRightStack = currentForecastStack.addStack(); | |
currentForecastRightStack.layoutVertically(); | |
currentForecastRightStack.centerAlignContent(); | |
currentForecastRightStack.setPadding(0, 0, 0, 10); // Extra padding for the icon | |
currentForecastRightStack.addSpacer(); | |
const currentConditionsIcon = currentForecastRightStack.addImage(currentData.conditionsIcon); | |
currentConditionsIcon.imageSize = new Size(60, 60); | |
currentForecastRightStack.addSpacer(); | |
//----- End currentForecastRightStack | |
//----- End currentForecastStack | |
//----- Start hourlyForecastStack | |
const hourlyForecastStack = contentStack.addStack(); | |
hourlyForecastStack.layoutHorizontally(); | |
hourlyForecastStack.setPadding(PADDING_VERT, PADDING_HORIZ, PADDING_VERT, PADDING_HORIZ); | |
hourlyForecastStack.backgroundColor = new Color(COLORS.hourlyBg); | |
// Whether or not precipitation percentages are showing | |
const showingPrecip = shouldShowPrecipitationProbability(hourlyData); | |
for( let i = 0; i < hourlyData.length; i++){ | |
//----- Start hourlyItemStack | |
const hourlyItemStack = hourlyForecastStack.addStack(); | |
hourlyItemStack.layoutVertically(); | |
hourlyItemStack.centerAlignContent(); | |
hourlyItemStack.spacing = showingPrecip ? 0 : 2; // Squish a little bit when there's extra data | |
//----- Start hourlyTimeStack | |
const hourlyTimeStack = hourlyItemStack.addStack(); | |
hourlyTimeStack.layoutHorizontally(); | |
hourlyTimeStack.addSpacer(); | |
const hourlyTimeValue = new Date(hourlyData[i].startTime).toLocaleString('en-US', { hour: 'numeric', hour12: true }); | |
const hourlyTime = hourlyTimeStack.addText(hourlyTimeValue); | |
hourlyTime.font = Font.systemFont(10); | |
hourlyTime.textColor = new Color(COLORS.timeText); | |
hourlyTime.centerAlignText(); | |
hourlyTimeStack.addSpacer(); | |
//----- End hourlyTimeStack | |
//----- Start hourlyConditionsStack | |
const hourlyConditionsStack = hourlyItemStack.addStack(); | |
hourlyConditionsStack.layoutHorizontally(); | |
hourlyConditionsStack.addSpacer(); | |
const hourlyCurrentConditionsIcon = hourlyConditionsStack.addImage(hourlyData[i].conditionsIcon); | |
hourlyCurrentConditionsIcon.imageSize = new Size(18, 18); | |
hourlyConditionsStack.addSpacer(); | |
//----- End hourlyConditionsStack | |
//----- Start hourlyProbabilityStack | |
if( showingPrecip ) { | |
const hourlyProbabilityStack = hourlyItemStack.addStack(); | |
hourlyProbabilityStack.layoutHorizontally(); | |
hourlyProbabilityStack.addSpacer(); | |
const precipitationProbability = hourlyData[i].values.precipitationProbability; | |
const hourlyProbability = hourlyProbabilityStack.addText(`${precipitationProbability}%`); | |
hourlyProbability.font = Font.systemFont(10); | |
hourlyProbability.textColor = new Color(COLORS.probabilityText); | |
hourlyProbability.centerAlignText(); | |
// We may hide this instance but still show the stack so everything aligns properly | |
if(precipitationProbability < PRECIPITATION_THRESHOLD){ | |
hourlyProbability.textOpacity = 0; | |
} | |
hourlyProbabilityStack.addSpacer(); | |
} | |
//----- End hourlyProbabilityStack | |
//----- Start hourlyTempStack | |
const hourlyTempStack = hourlyItemStack.addStack(); | |
hourlyTempStack.layoutHorizontally(); | |
hourlyTempStack.addSpacer(); | |
const hourlyTemp = hourlyTempStack.addText(formatTemperature(hourlyData[i].values.temperature)); | |
hourlyTemp.font = Font.systemFont(12); | |
hourlyTemp.centerAlignText(); | |
hourlyTemp.textColor = new Color(COLORS.text); | |
hourlyTempStack.addSpacer(); | |
//----- End hourlyTempStack | |
//----- End hourlyItemStack | |
} | |
//----- End hourlyForecastStack | |
} else { // Error message | |
contentStack.addSpacer(); | |
//----- Start errorStateStack | |
const errorStateStack = contentStack.addStack(); | |
errorStateStack.layoutVertically(); | |
errorStateStack.spacing = 8; | |
errorStateStack.setPadding(0, 0, PADDING_VERT, 0); | |
//----- Start errorIconStack | |
const errorIconStack = errorStateStack.addStack(); | |
errorIconStack.addSpacer(); | |
const errorIconSymbol = SFSymbol.named("exclamationmark.icloud.fill"); | |
const errorIcon = errorIconStack.addImage(errorIconSymbol.image); | |
errorIcon.imageSize = new Size(32, 32); | |
errorIcon.tintColor = new Color("#666666"); | |
errorIconStack.addSpacer(); | |
//----- End errorIconStack | |
//----- Start errorMessageStack | |
const errorMessageStack = errorStateStack.addStack(); | |
errorMessageStack.addSpacer(); | |
const errorMessage = errorMessageStack.addText('There was an error fetching weather data.\nPlease try again later.'); | |
errorMessage.textColor = new Color("#666666"); | |
errorMessage.font = Font.boldSystemFont(14); | |
errorMessage.centerAlignText(); | |
errorMessageStack.addSpacer(); | |
//----- End errorMessageStack | |
//----- End errorStateStack | |
contentStack.addSpacer(); | |
} | |
return widget; | |
} | |
//------------------------------------- | |
// API Calls | |
//------------------------------------- | |
/* | |
* Get the current location | |
*/ | |
async function getCurrentLocation() { | |
let latLong; | |
let currentLatLong = await Location.current(); | |
// If location is specified, use that. Otherwise, use the current device location. | |
if( !!LOCATION && LOCATION.length == 2 ){ | |
latLong = { | |
latitude: LOCATION[0], | |
longitude: LOCATION[1] | |
} | |
} else if( !!currentLatLong ){ | |
latLong = currentLatLong; | |
} | |
const currentLocationDetails = await Location.reverseGeocode(latLong.latitude, latLong.longitude); | |
console.log('LOCATION: ', currentLocationDetails); | |
const currentLocation = { | |
latitude: latLong.latitude, | |
longitude: latLong.longitude, | |
name: currentLocationDetails[0].postalAddress.city | |
}; | |
console.log('Found location:'); | |
if( !!currentLocation ){ | |
// Write location to the cache | |
cache.write('tomorrowLocation', currentLocation); | |
console.log(currentLocation); | |
return currentLocation || false; | |
} else { | |
// If unable to fetch location, try to read from the cache and return that instead. | |
// Read location from the cache | |
let cachedLocation = await cache.read('tomorrowLocation'); | |
console.log(cachedLocation); | |
return cachedLocation || false; | |
} | |
} | |
/* | |
* Get the icon for a given weather code | |
*/ | |
async function getConditionIconImage(weatherCode, size, time, sunrise, sunset) { | |
const iconName = CONDITIONS[weatherCode].iconName; | |
// Determine if it is daytime or nighttime | |
let dayNightCode = '0'; | |
const now = new Date(time); | |
const isBeforeDawn = now < new Date(sunrise); | |
const isAfterDusk = now > new Date(sunset); | |
if( CONDITIONS[weatherCode].hasNightIcon && (isBeforeDawn || isAfterDusk) ){ | |
dayNightCode = '1'; | |
} | |
console.log(`WEATHER CODE: ${weatherCode}`); | |
console.log(`DAYNIGHTCODE: ${dayNightCode}`); | |
const imageUrl = `https://raw.githubusercontent.com/Tomorrow-IO-API/tomorrow-weather-codes/master/V2_icons/${size}/png/${weatherCode}${dayNightCode}_${iconName}_${size}.png`; | |
const conditionIconRequest = await new Request(imageUrl); | |
const conditionIconImageData = await conditionIconRequest.loadImage(); | |
return conditionIconImageData; | |
} | |
/* | |
* Fetch the weather data from Tomorro.io | |
*/ | |
async function fetchWeatherData(latLong) { | |
const url = `https://api.tomorrow.io/v4/timelines?apikey=${API_TOKEN}`; | |
const headers = { | |
'content-type': 'application/json' | |
}; | |
const lat = latLong.latitude; | |
const lon = latLong.longitude; | |
const body = JSON.stringify({ | |
"location": `${lat},${lon}`, | |
"fields": [ | |
"temperature", | |
"temperatureApparent", | |
"precipitationProbability", | |
"sunsetTime", | |
"sunriseTime", | |
"temperatureMax", | |
"temperatureMin", | |
"weatherCode" | |
], | |
"units": UNITS, | |
"timesteps": [ | |
"current", | |
"1h", | |
"1d" | |
], | |
"timezone": "auto", | |
"startTime": "now", | |
"endTime": `nowPlus1d` | |
}); | |
let req = new Request(url); | |
req.method = "post"; | |
req.headers = headers; | |
req.body = body; | |
let res = await req.loadJSON(); | |
// Preview the data response for testing purposes | |
//let str = JSON.stringify(res, null, 2); | |
//await QuickLook.present(str); | |
console.log(res); | |
return res.data?.timelines || false; | |
} | |
//------------------------------------- | |
// Utility Functions | |
//------------------------------------- | |
/* | |
* Format temperature for display | |
*/ | |
function formatTemperature(temperature){ | |
return `${Math.round(temperature)}°` | |
} | |
/* | |
* Determine whether *any* entry in the list has a precipitation probability above the threshold | |
* Used to determine whether or not precipitation probability data should be displayed | |
*/ | |
function shouldShowPrecipitationProbability(data) { | |
let shouldShow = false; | |
for( let i = 0; i < data.length; i++ ){ | |
if( data[i].values.precipitationProbability > PRECIPITATION_THRESHOLD ){ | |
shouldShow = true; | |
} | |
} | |
return shouldShow; | |
} | |
/* | |
* Format weather data and add icons | |
*/ | |
async function formatWeatherData(timelines){ | |
if( !!timelines ){ | |
// Day-level info | |
let todayForecast = timelines[0].intervals[0]; | |
// Current forecast | |
let currentForecast = timelines[2].intervals[0]; | |
let currentForecastIcon = await getConditionIconImage(currentForecast.values.weatherCode, 'large', new Date(), todayForecast.values.sunriseTime, todayForecast.values.sunsetTime); | |
currentForecast.conditionsIcon = currentForecastIcon; | |
// Hourly forecast | |
const hourlyIntervals = timelines[1].intervals; | |
let hourlyForecast = []; | |
// Start at 1 because we don't want to include the current time' | |
for( let i = 1; i <= HOURS_AHEAD; i++ ){ | |
let newHourlyDataPoint = hourlyIntervals[i]; | |
// Add icon | |
let hourlyForecastIcon = await getConditionIconImage(hourlyIntervals[i].values.weatherCode, 'small', hourlyIntervals[i].startTime, todayForecast.values.sunriseTime, todayForecast.values.sunsetTime); | |
newHourlyDataPoint.conditionsIcon = hourlyForecastIcon; | |
hourlyForecast.push(newHourlyDataPoint); | |
} | |
let formattedData = { | |
current: currentForecast, | |
today: todayForecast, | |
hourly: hourlyForecast | |
}; | |
return formattedData; | |
} else { | |
return false; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment