Add Apple WeatherKit support
This commit is contained in:
@@ -21,7 +21,7 @@ The production version runs on Amazon Elastic Beanstalk (AWS EB) and therefore t
|
|||||||
---
|
---
|
||||||
## Installating a Local Weather Service
|
## Installating a Local Weather Service
|
||||||
|
|
||||||
If you would like to choose between different Weather Providers (currently OpenWeatherMap and DarkSky are supported) or use your local PWS to provide the weather information used by OpenSprinkler then you can install and configure the Weather Service on a device within your own local network.
|
If you would like to choose between different Weather Providers (currently OpenWeatherMap, DarkSky and Apple WeatherKit are supported) or use your local PWS to provide the weather information used by OpenSprinkler then you can install and configure the Weather Service on a device within your own local network.
|
||||||
|
|
||||||
You will need a 24x7 "always on" machine to host the service (this can be a Windows or Linux machine or even a Raspberry Pi device) provided it supports the `Node.js` environment.
|
You will need a 24x7 "always on" machine to host the service (this can be a Windows or Linux machine or even a Raspberry Pi device) provided it supports the `Node.js` environment.
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ WEATHER_PROVIDER=local
|
|||||||
PWS=WU
|
PWS=WU
|
||||||
```
|
```
|
||||||
|
|
||||||
|
* **Step 5d:** If you registered for the Apple WeatherKit API then also add these two lines to the .env file:
|
||||||
|
```
|
||||||
|
WEATHER_PROVIDER=Apple
|
||||||
|
WEATHERKIT_API_KEY=<YOUR APPLE WEATHERKIT KEY>
|
||||||
|
```
|
||||||
|
|
||||||
**Step 6:** Setup the Weather Server to start whenever the Raspberry Pi boots up using the built-in service manager:
|
**Step 6:** Setup the Weather Server to start whenever the Raspberry Pi boots up using the built-in service manager:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
4748
package-lock.json
generated
4748
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@
|
|||||||
"dotenv": "^8.0.0",
|
"dotenv": "^8.0.0",
|
||||||
"express": "^4.16.4",
|
"express": "^4.16.4",
|
||||||
"geo-tz": "^5.0.4",
|
"geo-tz": "^5.0.4",
|
||||||
|
"jsonwebtoken": "^9.0.0",
|
||||||
"mockdate": "^2.0.2",
|
"mockdate": "^2.0.2",
|
||||||
"moment-timezone": "^0.5.25",
|
"moment-timezone": "^0.5.25",
|
||||||
"node-cache": "^4.2.0",
|
"node-cache": "^4.2.0",
|
||||||
|
|||||||
@@ -68,9 +68,9 @@ export async function resolveCoordinates( location: string ): Promise< GeoCoordi
|
|||||||
* with an error if the request or JSON parsing fails. This error may contain information about the HTTP request or,
|
* with an error if the request or JSON parsing fails. This error may contain information about the HTTP request or,
|
||||||
* response including API keys and other sensitive information.
|
* response including API keys and other sensitive information.
|
||||||
*/
|
*/
|
||||||
export async function httpJSONRequest(url: string ): Promise< any > {
|
export async function httpJSONRequest(url: string, headers?, body?): Promise< any > {
|
||||||
try {
|
try {
|
||||||
const data: string = await httpRequest(url);
|
const data: string = await httpRequest(url, headers, body);
|
||||||
return JSON.parse(data);
|
return JSON.parse(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Reject the promise if there was an error making the request or parsing the JSON.
|
// Reject the promise if there was an error making the request or parsing the JSON.
|
||||||
@@ -362,19 +362,27 @@ function sendWateringData( res: express.Response, data: object, useJson: boolean
|
|||||||
* error if the request fails or returns a non-200 status code. This error may contain information about the HTTP
|
* error if the request fails or returns a non-200 status code. This error may contain information about the HTTP
|
||||||
* request or, response including API keys and other sensitive information.
|
* request or, response including API keys and other sensitive information.
|
||||||
*/
|
*/
|
||||||
async function httpRequest( url: string ): Promise< string > {
|
async function httpRequest( url: string, headers?, body? ): Promise< string > {
|
||||||
return new Promise< any >( ( resolve, reject ) => {
|
return new Promise< any >( ( resolve, reject ) => {
|
||||||
|
|
||||||
const splitUrl: string[] = url.match( filters.url );
|
const splitUrl: string[] = url.match( filters.url );
|
||||||
const isHttps = url.startsWith("https");
|
const isHttps = url.startsWith("https");
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
headers = headers || {};
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
headers['Content-Length'] = Buffer.byteLength(body);
|
||||||
|
}
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
host: splitUrl[ 1 ],
|
host: splitUrl[ 1 ],
|
||||||
port: splitUrl[ 2 ] || ( isHttps ? 443 : 80 ),
|
port: splitUrl[ 2 ] || ( isHttps ? 443 : 80 ),
|
||||||
path: splitUrl[ 3 ]
|
path: splitUrl[ 3 ],
|
||||||
|
method: 'GET',
|
||||||
|
headers
|
||||||
};
|
};
|
||||||
|
|
||||||
( isHttps ? https : http ).get( options, ( response ) => {
|
const request = ( isHttps ? https : http ).request( options, ( response ) => {
|
||||||
if ( response.statusCode !== 200 ) {
|
if ( response.statusCode !== 200 ) {
|
||||||
reject( `Received ${ response.statusCode } status code for URL '${ url }'.` );
|
reject( `Received ${ response.statusCode } status code for URL '${ url }'.` );
|
||||||
return;
|
return;
|
||||||
@@ -396,6 +404,12 @@ async function httpRequest( url: string ): Promise< string > {
|
|||||||
// If the HTTP request fails, reject the promise
|
// If the HTTP request fails, reject the promise
|
||||||
reject( err );
|
reject( err );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
request.write(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.end();
|
||||||
} );
|
} );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
235
routes/weatherProviders/Apple.ts
Normal file
235
routes/weatherProviders/Apple.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import * as moment from "moment-timezone";
|
||||||
|
import * as jwt from "jsonwebtoken";
|
||||||
|
|
||||||
|
import { GeoCoordinates, WeatherData, ZimmermanWateringData } from "../../types";
|
||||||
|
import { httpJSONRequest } from "../weather";
|
||||||
|
import { WeatherProvider } from "./WeatherProvider";
|
||||||
|
import { approximateSolarRadiation, CloudCoverInfo, EToData } from "../adjustmentMethods/EToAdjustmentMethod";
|
||||||
|
import { CodedError, ErrorCode } from "../../errors";
|
||||||
|
|
||||||
|
export default class AppleWeatherProvider extends WeatherProvider {
|
||||||
|
|
||||||
|
private readonly API_KEY: string;
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
if (!process.env.APPLE_PRIVATE_KEY) {
|
||||||
|
throw "APPLE_PRIVATE_KEY environment variable is not defined.";
|
||||||
|
}
|
||||||
|
|
||||||
|
this.API_KEY = jwt.sign(
|
||||||
|
{ sub: process.env.APPLE_SERVICE_ID },
|
||||||
|
process.env.APPLE_PRIVATE_KEY,
|
||||||
|
{
|
||||||
|
jwtid: `${process.env.APPLE_TEAM_ID}.${process.env.APPLE_SERVICE_ID}`,
|
||||||
|
issuer: process.env.APPLE_TEAM_ID,
|
||||||
|
expiresIn: "10y",
|
||||||
|
keyid: process.env.APPLE_KEY_ID,
|
||||||
|
algorithm: "ES256",
|
||||||
|
header: { id: `${process.env.APPLE_TEAM_ID}.${process.env.APPLE_SERVICE_ID}` }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getWateringData( coordinates: GeoCoordinates ): Promise< ZimmermanWateringData > {
|
||||||
|
// The Unix timestamp of 24 hours ago.
|
||||||
|
const yesterdayTimestamp: string = moment().subtract( 1, "day" ).toISOString();
|
||||||
|
|
||||||
|
const yesterdayUrl = `https://weatherkit.apple.com/api/v1/weather/en/${ coordinates[ 0 ] }/${ coordinates[ 1 ] }?dataSets=forecastHourly&hourlyStart=${yesterdayTimestamp}&timezone=UTC`
|
||||||
|
|
||||||
|
let yesterdayData;
|
||||||
|
try {
|
||||||
|
yesterdayData = await httpJSONRequest( yesterdayUrl, {Authorization: `Bearer ${this.API_KEY}`} );
|
||||||
|
} catch ( err ) {
|
||||||
|
console.error( "Error retrieving weather information from Apple:", err );
|
||||||
|
throw new CodedError( ErrorCode.WeatherApiError );
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !yesterdayData.forecastHourly || !yesterdayData.forecastHourly.hours ) {
|
||||||
|
throw new CodedError( ErrorCode.MissingWeatherField );
|
||||||
|
}
|
||||||
|
|
||||||
|
const samples = [
|
||||||
|
...yesterdayData.forecastHourly.hours
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fail if not enough data is available.
|
||||||
|
// There will only be 23 samples on the day that daylight saving time begins.
|
||||||
|
if ( samples.length < 23 ) {
|
||||||
|
throw new CodedError( ErrorCode.InsufficientWeatherData );
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = { temp: 0, humidity: 0, precip: 0 };
|
||||||
|
for ( const sample of samples ) {
|
||||||
|
/*
|
||||||
|
* If temperature or humidity is missing from a sample, the total will become NaN. This is intended since
|
||||||
|
* calculateWateringScale will treat NaN as a missing value and temperature/humidity can't be accurately
|
||||||
|
* calculated when data is missing from some samples (since they follow diurnal cycles and will be
|
||||||
|
* significantly skewed if data is missing for several consecutive hours).
|
||||||
|
*/
|
||||||
|
totals.temp += this.celsiusToFahrenheit(sample.temperature);
|
||||||
|
totals.humidity += sample.humidity;
|
||||||
|
// This field may be missing from the response if it is snowing.
|
||||||
|
totals.precip += this.mmToInchesPerHour(sample.precipitationIntensity || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
weatherProvider: "Apple",
|
||||||
|
temp: totals.temp / samples.length,
|
||||||
|
humidity: totals.humidity / samples.length * 100,
|
||||||
|
precip: totals.precip,
|
||||||
|
raining: samples[ samples.length - 1 ].precipitationIntensity > 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getWeatherData( coordinates: GeoCoordinates ): Promise< WeatherData > {
|
||||||
|
const forecastUrl = `https://weatherkit.apple.com/api/v1/weather/en/${ coordinates[ 0 ] }/${ coordinates[ 1 ] }?dataSets=currentWeather,forecastDaily&timezone=UTC`
|
||||||
|
|
||||||
|
let forecast;
|
||||||
|
try {
|
||||||
|
forecast = await httpJSONRequest( forecastUrl, {Authorization: `Bearer ${this.API_KEY}`} );
|
||||||
|
} catch ( err ) {
|
||||||
|
console.error( "Error retrieving weather information from Apple:", err );
|
||||||
|
throw "An error occurred while retrieving weather information from Apple."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !forecast.currentWeather || !forecast.forecastDaily || !forecast.forecastDaily.days ) {
|
||||||
|
throw "Necessary field(s) were missing from weather information returned by Apple.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const weather: WeatherData = {
|
||||||
|
weatherProvider: "Apple",
|
||||||
|
temp: Math.floor( this.celsiusToFahrenheit( forecast.currentWeather.temperature ) ),
|
||||||
|
humidity: Math.floor( forecast.currentWeather.humidity * 100 ),
|
||||||
|
wind: Math.floor( this.kphToMph( forecast.currentWeather.windSpeed ) ),
|
||||||
|
description: forecast.currentWeather.conditionCode,
|
||||||
|
icon: this.getOWMIconCode( forecast.currentWeather.conditionCode ),
|
||||||
|
|
||||||
|
region: "",
|
||||||
|
city: "",
|
||||||
|
minTemp: Math.floor( this.celsiusToFahrenheit( forecast.forecastDaily.days[ 0 ].temperatureMin ) ),
|
||||||
|
maxTemp: Math.floor( this.celsiusToFahrenheit( forecast.forecastDaily.days[ 0 ].temperatureMax ) ),
|
||||||
|
precip: this.mmToInchesPerHour( forecast.currentWeather.precipitationIntensity ) * 24,
|
||||||
|
forecast: []
|
||||||
|
};
|
||||||
|
|
||||||
|
for ( let index = 0; index < forecast.forecastDaily.days.length; index++ ) {
|
||||||
|
weather.forecast.push( {
|
||||||
|
temp_min: Math.floor( this.celsiusToFahrenheit( forecast.forecastDaily.days[ index ].temperatureMin ) ),
|
||||||
|
temp_max: Math.floor( this.celsiusToFahrenheit( forecast.forecastDaily.days[ index ].temperatureMax ) ),
|
||||||
|
date: moment(forecast.forecastDaily.days[ index ].forecastStart).unix(),
|
||||||
|
icon: this.getOWMIconCode( forecast.forecastDaily.days[ index ].conditionCode ),
|
||||||
|
description: forecast.forecastDaily.days[ index ].conditionCode
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
return weather;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getEToData( coordinates: GeoCoordinates ): Promise< EToData > {
|
||||||
|
// The Unix timestamp of 24 hours ago.
|
||||||
|
const yesterdayTimestamp: string = moment().subtract( 1, "day" ).toISOString();
|
||||||
|
|
||||||
|
const yesterdayUrl = `https://weatherkit.apple.com/api/v1/weather/en/${ coordinates[ 0 ] }/${ coordinates[ 1 ] }?dataSets=forecastHourly,forecastDaily&hourlyStart=${yesterdayTimestamp}&dailyStart=${yesterdayTimestamp}&dailyEnd=${moment().toISOString()}&timezone=UTC`
|
||||||
|
|
||||||
|
let historicData;
|
||||||
|
try {
|
||||||
|
historicData = await httpJSONRequest( yesterdayUrl, {Authorization: `Bearer ${this.API_KEY}`} );
|
||||||
|
} catch (err) {
|
||||||
|
throw new CodedError( ErrorCode.WeatherApiError );
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloudCoverInfo: CloudCoverInfo[] = historicData.forecastHourly.hours.map( ( hour ): CloudCoverInfo => {
|
||||||
|
return {
|
||||||
|
startTime: moment( hour.forecastStart ),
|
||||||
|
endTime: moment( hour.forecastStart ).add( 1, "hours" ),
|
||||||
|
cloudCover: hour.cloudCover
|
||||||
|
};
|
||||||
|
} );
|
||||||
|
|
||||||
|
let minHumidity: number = undefined, maxHumidity: number = undefined;
|
||||||
|
for ( const hour of historicData.forecastHourly.hours ) {
|
||||||
|
// Skip hours where humidity measurement does not exist to prevent result from being NaN.
|
||||||
|
if ( hour.humidity === undefined ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If minHumidity or maxHumidity is undefined, these comparisons will yield false.
|
||||||
|
minHumidity = minHumidity < hour.humidity ? minHumidity : hour.humidity;
|
||||||
|
maxHumidity = maxHumidity > hour.humidity ? maxHumidity : hour.humidity;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
weatherProvider: "Apple",
|
||||||
|
periodStartTime: moment(historicData.forecastHourly.hours[ 0 ].forecastStart).unix(),
|
||||||
|
minTemp: this.celsiusToFahrenheit( historicData.forecastDaily.days[ 0 ].temperatureMin ),
|
||||||
|
maxTemp: this.celsiusToFahrenheit( historicData.forecastDaily.days[ 0 ].temperatureMax ),
|
||||||
|
minHumidity: minHumidity * 100,
|
||||||
|
maxHumidity: maxHumidity * 100,
|
||||||
|
solarRadiation: approximateSolarRadiation( cloudCoverInfo, coordinates ),
|
||||||
|
// Assume wind speed measurements are taken at 2 meters.
|
||||||
|
windSpeed: this.kphToMph( historicData.forecastDaily.days[ 0 ].windSpeed ),
|
||||||
|
precip: this.mmToInchesPerHour( historicData.forecastDaily.days[ 0 ].precipIntensity || 0 ) * 24
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public shouldCacheWateringScale(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOWMIconCode(icon: string) {
|
||||||
|
switch(icon.toLowerCase()) {
|
||||||
|
case "mostlyclear":
|
||||||
|
case "partlycloudy":
|
||||||
|
return "02n";
|
||||||
|
case "mostlycloudy":
|
||||||
|
case "cloudy":
|
||||||
|
case "smokey":
|
||||||
|
return "03d";
|
||||||
|
case "foggy":
|
||||||
|
case "haze":
|
||||||
|
case "windy":
|
||||||
|
case "breezy":
|
||||||
|
return "50d";
|
||||||
|
case "sleet":
|
||||||
|
case "snow":
|
||||||
|
case "frigid":
|
||||||
|
case "hail":
|
||||||
|
case "flurries":
|
||||||
|
case "sunflurries":
|
||||||
|
case "wintrymix":
|
||||||
|
case "blizzard":
|
||||||
|
case "blowingsnow":
|
||||||
|
case "freezingdrizzle":
|
||||||
|
case "freezingrain":
|
||||||
|
case "heavysnow":
|
||||||
|
return "13d";
|
||||||
|
case "rain":
|
||||||
|
case "drizzle":
|
||||||
|
case "heavyrain":
|
||||||
|
case "isolatedthunderstorms":
|
||||||
|
case "sunshowers":
|
||||||
|
case "scatteredthunderstorms":
|
||||||
|
case "strongstorms":
|
||||||
|
case "thunderstorms":
|
||||||
|
return "10d";
|
||||||
|
case "clear":
|
||||||
|
default:
|
||||||
|
return "01d";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private celsiusToFahrenheit(celsius) {
|
||||||
|
return (celsius * 9/5) + 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mmToInchesPerHour(mmPerHour) {
|
||||||
|
return mmPerHour * 0.03937007874;
|
||||||
|
}
|
||||||
|
|
||||||
|
private kphToMph(kph) {
|
||||||
|
return kph * 0.621371;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
4
types.ts
4
types.ts
@@ -74,5 +74,5 @@ export interface ZimmermanWateringData extends BaseWateringData {
|
|||||||
raining: boolean;
|
raining: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WeatherProviderId = "OWM" | "DarkSky" | "local" | "mock" | "WUnderground";
|
export type WeatherProviderId = "OWM" | "DarkSky" | "Apple" | "local" | "mock" | "WUnderground";
|
||||||
export type WeatherProviderShortId = "OWM" | "DS" | "local" | "mock" | "WU";
|
export type WeatherProviderShortId = "OWM" | "DS" | "Apple" | "local" | "mock" | "WU";
|
||||||
|
|||||||
Reference in New Issue
Block a user