Compare commits

...

14 Commits

Author SHA1 Message Date
ec142d1635 Update deps. 2024-01-17 07:16:32 +00:00
6c23337f02 Make docker build work. 2024-01-17 07:16:16 +00:00
b03cb436b0 Checking local config. 2024-01-17 07:15:55 +00:00
60cfd4820a Build local docker image. 2024-01-17 07:15:36 +00:00
Samer Albahra
b486f6d26a Update version 2023-05-22 10:26:19 -04:00
Ray
873b6d941f fix ETo precipitation issue: the variable name is precipitationAmount, not intensity, which also implies no multiplying 24 2023-05-21 23:37:42 -04:00
Ray
688a3adaa3 fix forecast windspeed issue 2023-04-09 23:22:34 -04:00
Samer Albahra
59080b77a4 Fix undefined issue for package description and version 2023-04-08 09:39:28 -04:00
Samer Albahra
60ee071698 Update version number 2023-04-01 14:14:52 -04:00
Samer Albahra
2d5cf0dbf3 Add Apple WeatherKit support 2023-03-20 18:40:05 -04:00
Ray
a91167187f Merge pull request #132 from rmloeb/patch-3
Update davis-vantage.md
2023-01-16 21:56:25 -05:00
Roger Loeb
a4d7461dcd Update davis-vantage.md
Added workaround for OSPI port 3000 limitation.
2022-06-21 12:07:35 -06:00
Samer Albahra
286e87c812 Merge pull request #131 from rmloeb/patch-2 2022-06-20 07:37:26 -05:00
Roger Loeb
9aabe72f69 Update davis-vantage.md 2022-06-20 06:29:05 -06:00
13 changed files with 5352 additions and 29 deletions

5
.env Normal file
View File

@@ -0,0 +1,5 @@
HOST=0.0.0.0
PORT=80
WEATHER_PROVIDER=local
PWS=WU
LOCAL_PERSISTENCE=true

1
.gitignore vendored
View File

@@ -1,6 +1,5 @@
.DS_Store .DS_Store
node_modules node_modules
.env
coverage/* coverage/*
npm-debug.log npm-debug.log
.idea .idea

View File

@@ -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.
@@ -80,4 +80,4 @@ docker start osweather
# Instead of the above, use this for testing/troubleshooting by running it in the foreground: # Instead of the above, use this for testing/troubleshooting by running it in the foreground:
docker run --rm -it -p 3000:3000 opensprinkler-weather 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`

View File

@@ -1,4 +1,4 @@
#!/bin/bash -e #!/usr/bin/env bash
while getopts ":hp:r" opt; do while getopts ":hp:r" opt; do
case ${opt} in case ${opt} in
@@ -47,4 +47,4 @@ if ! grep 'HOST=0.0.0.0' .env > /dev/null ; then
exit 1 exit 1
fi fi
docker build -t opensprinkler-weather . docker build -t gitea.v.paler.net/ppetru/opensprinkler-weather . && docker push gitea.v.paler.net/ppetru/opensprinkler-weather

View File

@@ -28,3 +28,5 @@ Actual readings from your PWS should now be flowing to weather-service. Make sur
**Testing** **Testing**
To immediately observe the data feed, open Davis WeatherLink, click on File | Wunderground Settings, then click the "Test" box. 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.

View File

@@ -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:
``` ```

5079
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "os-weather-service", "name": "os-weather-service",
"description": "OpenSprinkler Weather Service", "description": "OpenSprinkler Weather Service",
"version": "2.0.4", "version": "2.0.6",
"repository": "https://github.com/OpenSprinkler/Weather-Weather", "repository": "https://github.com/OpenSprinkler/Weather-Weather",
"scripts": { "scripts": {
"test": "mocha --exit --require ts-node/register **/*.spec.ts", "test": "mocha --exit --require ts-node/register **/*.spec.ts",
@@ -10,7 +10,7 @@
"debug": "node --inspect=9229 js/server", "debug": "node --inspect=9229 js/server",
"bundle": "npm run compile && zip weather.zip -r js package.json package-lock.json", "bundle": "npm run compile && zip weather.zip -r js package.json package-lock.json",
"deploy": "npm run bundle && eb deploy", "deploy": "npm run bundle && eb deploy",
"compile": "tsc" "compile": "./node_modules/typescript/bin/tsc"
}, },
"dependencies": { "dependencies": {
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -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",

View File

@@ -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();
} ); } );
} }

View 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;
}
}

View File

@@ -7,6 +7,7 @@ import * as cors from "cors";
import * as weather from "./routes/weather"; import * as weather from "./routes/weather";
import * as local from "./routes/weatherProviders/local"; import * as local from "./routes/weatherProviders/local";
import * as baselineETo from "./routes/baselineETo"; import * as baselineETo from "./routes/baselineETo";
import * as packageJson from "./package.json";
let host = process.env.HOST || "127.0.0.1", let host = process.env.HOST || "127.0.0.1",
port = parseInt( process.env.PORT ) || 3000; port = parseInt( process.env.PORT ) || 3000;
@@ -30,7 +31,7 @@ if ( pws === "WU" ) {
} }
app.get( "/", function( req, res ) { 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 // Handle requests matching /baselineETo
@@ -45,9 +46,9 @@ app.use( function( req, res ) {
// Start listening on the service port // Start listening on the service port
app.listen( port, host, function() { 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" ) { 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 );
} }
} ); } );

View File

@@ -5,7 +5,8 @@
"noEmitOnError": true, "noEmitOnError": true,
"outDir": "js/", "outDir": "js/",
"sourceMap": true, "sourceMap": true,
"skipLibCheck": true "skipLibCheck": true,
"resolveJsonModule": true
}, },
"include": [ "include": [
"errors.ts", "errors.ts",

View File

@@ -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";