Skip to content

Instantly share code, notes, and snippets.

@alexberkowitz
Last active April 20, 2024 01:12
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save alexberkowitz/3abd07ecd81a4110f6b4670eef95e02a to your computer and use it in GitHub Desktop.
Save alexberkowitz/3abd07ecd81a4110f6b4670eef95e02a to your computer and use it in GitHub Desktop.
Tomorrow.io Widget for Scriptable
// 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