Compare commits
10 Commits
f897c280ac
...
b486f6d26a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b486f6d26a | ||
|
|
873b6d941f | ||
|
|
688a3adaa3 | ||
|
|
59080b77a4 | ||
|
|
60ee071698 | ||
|
|
2d5cf0dbf3 | ||
|
|
a91167187f | ||
|
|
a4d7461dcd | ||
|
|
286e87c812 | ||
|
|
9aabe72f69 |
@@ -21,7 +21,7 @@ The production version runs on Amazon Elastic Beanstalk (AWS EB) and therefore t
|
||||
---
|
||||
## 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.
|
||||
|
||||
@@ -80,4 +80,4 @@ docker start osweather
|
||||
# Instead of the above, use this for testing/troubleshooting by running it in the foreground:
|
||||
docker run --rm -it -p 3000:3000 opensprinkler-weather
|
||||
```
|
||||
Note: to expose a different port, change `-p 3000:3000` to, eg `-p12345:3000`
|
||||
Note: to expose a different port, change `-p 3000:3000` to, eg `-p12345:3000`
|
||||
@@ -28,3 +28,5 @@ Actual readings from your PWS should now be flowing to weather-service. Make sur
|
||||
**Testing**
|
||||
|
||||
To immediately observe the data feed, open Davis WeatherLink, click on File | Wunderground Settings, then click the "Test" box.
|
||||
|
||||
Note: this procedure does not work if you are using an OSPI (Open Sprinkler running on a Raspberry Pi), because OSPI requires that the OpenSprinkler-Weather-Service use port 3000 and the HOSTS file redirection does not support a port specification. If you are running the weather-server on a system that supports iptables, you can work around this by using iptables to redirect port 80 to port 3000 on the server host. See https://o7planning.org/11363/redirect-port-80-443-on-ubuntu-using-iptables for guidance on how to do this.
|
||||
|
||||
@@ -72,6 +72,12 @@ WEATHER_PROVIDER=local
|
||||
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:
|
||||
|
||||
```
|
||||
|
||||
4748
package-lock.json
generated
4748
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "os-weather-service",
|
||||
"description": "OpenSprinkler Weather Service",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.6",
|
||||
"repository": "https://github.com/OpenSprinkler/Weather-Weather",
|
||||
"scripts": {
|
||||
"test": "mocha --exit --require ts-node/register **/*.spec.ts",
|
||||
@@ -18,6 +18,7 @@
|
||||
"dotenv": "^8.0.0",
|
||||
"express": "^4.16.4",
|
||||
"geo-tz": "^5.0.4",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"mockdate": "^2.0.2",
|
||||
"moment-timezone": "^0.5.25",
|
||||
"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,
|
||||
* 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 {
|
||||
const data: string = await httpRequest(url);
|
||||
const data: string = await httpRequest(url, headers, body);
|
||||
return JSON.parse(data);
|
||||
} catch (err) {
|
||||
// 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
|
||||
* 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 ) => {
|
||||
|
||||
const splitUrl: string[] = url.match( filters.url );
|
||||
const isHttps = url.startsWith("https");
|
||||
|
||||
if (body) {
|
||||
headers = headers || {};
|
||||
headers['Content-Type'] = 'application/json';
|
||||
headers['Content-Length'] = Buffer.byteLength(body);
|
||||
}
|
||||
|
||||
const options = {
|
||||
host: splitUrl[ 1 ],
|
||||
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 ) {
|
||||
reject( `Received ${ response.statusCode } status code for URL '${ url }'.` );
|
||||
return;
|
||||
@@ -396,6 +404,12 @@ async function httpRequest( url: string ): Promise< string > {
|
||||
// If the HTTP request fails, reject the promise
|
||||
reject( err );
|
||||
} );
|
||||
|
||||
if (body) {
|
||||
request.write(body);
|
||||
}
|
||||
|
||||
request.end();
|
||||
} );
|
||||
}
|
||||
|
||||
|
||||
237
routes/weatherProviders/Apple.ts
Normal file
237
routes/weatherProviders/Apple.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
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;
|
||||
}
|
||||
|
||||
let windSpeed = ( historicData.forecastDaily.days[ 0 ].daytimeForecast.windSpeed + historicData.forecastDaily.days[ 0 ].overnightForecast.windSpeed ) / 2;
|
||||
|
||||
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( windSpeed ),
|
||||
precip: this.mmToInchesPerHour( historicData.forecastDaily.days[ 0 ].precipitationAmount || 0 )
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import * as cors from "cors";
|
||||
import * as weather from "./routes/weather";
|
||||
import * as local from "./routes/weatherProviders/local";
|
||||
import * as baselineETo from "./routes/baselineETo";
|
||||
import * as packageJson from "./package.json";
|
||||
|
||||
let host = process.env.HOST || "127.0.0.1",
|
||||
port = parseInt( process.env.PORT ) || 3000;
|
||||
@@ -30,7 +31,7 @@ if ( pws === "WU" ) {
|
||||
}
|
||||
|
||||
app.get( "/", function( req, res ) {
|
||||
res.send( process.env.npm_package_description + " v" + process.env.npm_package_version );
|
||||
res.send( packageJson.description + " v" + packageJson.version );
|
||||
} );
|
||||
|
||||
// Handle requests matching /baselineETo
|
||||
@@ -45,9 +46,9 @@ app.use( function( req, res ) {
|
||||
|
||||
// Start listening on the service port
|
||||
app.listen( port, host, function() {
|
||||
console.log( "%s now listening on %s:%d", process.env.npm_package_description, host, port );
|
||||
console.log( "%s now listening on %s:%d", packageJson.description, host, port );
|
||||
|
||||
if (pws !== "none" ) {
|
||||
console.log( "%s now listening for local weather stream", process.env.npm_package_description );
|
||||
console.log( "%s now listening for local weather stream", packageJson.description );
|
||||
}
|
||||
} );
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"noEmitOnError": true,
|
||||
"outDir": "js/",
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"errors.ts",
|
||||
|
||||
4
types.ts
4
types.ts
@@ -74,5 +74,5 @@ export interface ZimmermanWateringData extends BaseWateringData {
|
||||
raining: boolean;
|
||||
}
|
||||
|
||||
export type WeatherProviderId = "OWM" | "DarkSky" | "local" | "mock" | "WUnderground";
|
||||
export type WeatherProviderShortId = "OWM" | "DS" | "local" | "mock" | "WU";
|
||||
export type WeatherProviderId = "OWM" | "DarkSky" | "Apple" | "local" | "mock" | "WUnderground";
|
||||
export type WeatherProviderShortId = "OWM" | "DS" | "Apple" | "local" | "mock" | "WU";
|
||||
|
||||
Reference in New Issue
Block a user