├── src ├── bootstrap.ts ├── test │ ├── expected.json │ └── etoTest.json ├── routes │ ├── adjustmentMethods │ │ ├── ManualAdjustmentMethod.ts │ │ ├── RainDelayAdjustmentMethod.ts │ │ ├── EToAdjustmentMethod.spec.ts │ │ ├── AdjustmentMethod.ts │ │ ├── ZimmermanAdjustmentMethod.ts │ │ └── EToAdjustmentMethod.ts │ ├── geocoders │ │ ├── WUnderground.ts │ │ ├── GoogleMaps.ts │ │ └── Geocoder.ts │ ├── weather.spec.ts │ ├── weatherProviders │ │ ├── WeatherProvider.ts │ │ ├── OWM.ts │ │ ├── local.ts │ │ ├── WUnderground.ts │ │ ├── PirateWeather.ts │ │ ├── AccuWeather.ts │ │ ├── DWD.ts │ │ ├── OpenMeteo.ts │ │ └── Apple.ts │ └── baselineETo.ts ├── cache.ts ├── server.ts ├── errors.ts └── types.ts ├── baselineEToData ├── baseline.sh ├── entrypoint.sh ├── prepareData.sh ├── README.md └── dataPreparer.c ├── .editorconfig ├── .vscode ├── extensions.json ├── settings.json ├── tasks.json └── launch.json ├── docs ├── dwd.md ├── open-meteo.md ├── pws-protocol.md ├── weewx.md ├── davis-vantage.md ├── netatmo.md ├── man-in-middle.md ├── wifi-hotspot.md └── local-installation.md ├── .gitignore ├── tsconfig.json ├── Dockerfile ├── scripts └── server.js ├── package.json ├── README.md └── .github └── workflows └── build-ci.yml /src/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | process.env.GEO_TZ_DATA_PATH = process.env.GEO_TZ_DATA_PATH || path.join(__dirname, 'data'); -------------------------------------------------------------------------------- /baselineEToData/baseline.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Move the last pass to the output directory. 3 | mv $(ls -1t Baseline_ETo_Data-Pass_*.bin | head -n1) Baseline_ETo_Data.bin 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.ts] 4 | indent_style = tab 5 | indent_size = 2 6 | trim_trailing_whitespace = true 7 | end_of_line = lf 8 | insert_final_newline = true 9 | 10 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "streetsidesoftware.code-spell-checker", 4 | "DavidAnson.vscode-markdownlint", 5 | "eamodio.gitlens" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /baselineEToData/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | /prepareData.sh $1 3 | # Move the last pass to the output directory. 4 | mv $(ls Baseline_ETo_Data-Pass_*.bin | tail -n1) /output/Baseline_ETo_Data.bin 5 | -------------------------------------------------------------------------------- /docs/dwd.md: -------------------------------------------------------------------------------- 1 | # New weather provider: DWD (Deutscher Wetter Dienst=German Weather Service) 2 | 3 | **!!only usable for german locations!!** 4 | 5 | This provider uses https://brightSky.dev for querying data for a given location. 6 | Just define `WEATHER_PROVIDER=DWD` in your .env File 7 | 8 | ## Supports ETO as well 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | .env 4 | coverage/* 5 | npm-debug.log 6 | .idea 7 | js 8 | weather.zip 9 | baselineEToData/*.bin 10 | baselineEToData/*.png 11 | baselineEToData/*.tif 12 | baselineEToData/dataPreparer[.exe] 13 | observations.json 14 | geocoderCache.json 15 | dist 16 | pnpm-lock.yaml 17 | package-lock.json 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Node10", 6 | "noImplicitReturns": true, 7 | "noEmitOnError": true, 8 | "outDir": "js/", 9 | "sourceMap": true, 10 | "skipLibCheck": true, 11 | "resolveJsonModule": true, 12 | "allowSyntheticDefaultImports": true 13 | }, 14 | "include": [ 15 | "src/errors.ts", 16 | "src/server.ts", 17 | "src/types.ts", 18 | "src/routes/**/*" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /docs/open-meteo.md: -------------------------------------------------------------------------------- 1 | # OpenMeteo (https://open-meteo.com/en) 2 | 3 | **WORLDWIDE WEATHER SERVICE** 4 | 5 | Open-Meteo combines local (2 km resolution) and global (11 km) weather models from national weather services. For every location on earth, the best forecast is available. 6 | National weather services include Deutscher Wetter Dienst (DWD), National Oceanic and Atmospheric Administration (NOAA), Meteofrance and Koninklijk Nederlands Meteorologisch Instituut. 7 | Just define `WEATHER_PROVIDER=OpenMeteo` in your .env File 8 | 9 | **supports ETO aswell** 10 | -------------------------------------------------------------------------------- /src/test/expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "noWeather": { 3 | "01002": { 4 | "tz": 32, 5 | "rawData": { 6 | "wp": "Manual" 7 | }, 8 | "sunrise": 332, 9 | "sunset": 1203, 10 | "eip": 2130706433, 11 | "errCode": 0 12 | } 13 | }, 14 | "adjustment1": { 15 | "01002": { 16 | "scale": 0, 17 | "tz": 32, 18 | "sunrise": 332, 19 | "sunset": 1203, 20 | "eip": 2130706433, 21 | "rawData": { 22 | "h": 98.5, 23 | "p": 1.09, 24 | "t": 70.8, 25 | "raining": 1, 26 | "wp": "OWM" 27 | }, 28 | "errCode": 0 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.insertSpaces": true, 3 | "git.promptToSaveFilesBeforeCommit": true, 4 | "files.trimTrailingWhitespace": true, 5 | "files.exclude": { 6 | ".git": true, 7 | "node_modules": true, 8 | "package-lock.json": true, 9 | "pnpm-lock.json": true, 10 | "js": true, 11 | "weather.zip": true 12 | }, 13 | "search.exclude": { 14 | ".git": true, 15 | "node_modules": true, 16 | "package-lock.json": true, 17 | "pnpm-lock.json": true, 18 | "js": true, 19 | "weather.zip": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/routes/adjustmentMethods/ManualAdjustmentMethod.ts: -------------------------------------------------------------------------------- 1 | import { AdjustmentMethod, AdjustmentMethodResponse } from "./AdjustmentMethod"; 2 | 3 | 4 | /** 5 | * Does not change the watering scale (only time data will be returned). 6 | */ 7 | async function calculateManualWateringScale( ): Promise< AdjustmentMethodResponse > { 8 | return { 9 | scale: undefined, 10 | rawData: { 11 | wp: "Manual", 12 | }, 13 | wateringData: undefined, 14 | ttl: 0 15 | } 16 | } 17 | 18 | 19 | const ManualAdjustmentMethod: AdjustmentMethod = { 20 | calculateWateringScale: calculateManualWateringScale 21 | }; 22 | export default ManualAdjustmentMethod; 23 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "dev", 9 | "group": { 10 | "kind": "build", 11 | "isDefault": true 12 | }, 13 | "label": "development", 14 | "isBackground": true, 15 | "problemMatcher": [] 16 | }, 17 | { 18 | "label": "NPM Compile", 19 | "type": "npm", 20 | "script": "compile", 21 | "problemMatcher": [] 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /baselineEToData/prepareData.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "Compiling dataPreparer.c..." 3 | gcc -std=c99 -o dataPreparer dataPreparer.c 4 | 5 | echo "Downloading ocean mask image..." 6 | wget http://static1.squarespace.com/static/58586fa5ebbd1a60e7d76d3e/t/59394abb37c58179160775fa/1496926933082/Ocean_Mask.png 7 | 8 | echo "Converting ocean mask image to binary format..." 9 | magick Ocean_Mask.png -depth 8 gray:Ocean_Mask.bin 10 | 11 | echo "Downloading MOD16 GeoTIFF..." 12 | wget http://files.ntsg.umt.edu/data/NTSG_Products/MOD16/MOD16A3.105_MERRAGMAO/Geotiff/MOD16A3_PET_2000_to_2013_mean.tif 13 | 14 | echo "Converting MOD16 GeoTIFF to binary format..." 15 | magick MOD16A3_PET_2000_to_2013_mean.tif -depth 16 gray:MOD16A3_PET_2000_to_2013_mean.bin 16 | 17 | echo "Preparing data..." 18 | ./dataPreparer $1 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest AS build_eto 2 | WORKDIR /eto 3 | 4 | RUN apk add --no-cache tiff imagemagick gcc libc-dev build-base 5 | 6 | COPY /baselineEToData/dataPreparer.c ./ 7 | COPY /baselineEToData/prepareData.sh ./ 8 | COPY /baselineEToData/baseline.sh ./ 9 | 10 | RUN chmod +x ./prepareData.sh ./baseline.sh 11 | 12 | RUN ash ./prepareData.sh 20 13 | RUN ash ./baseline.sh 14 | RUN rm Baseline_ETo_Data-Pass_*.bin 15 | 16 | FROM node:lts-alpine AS build_node 17 | WORKDIR /weather 18 | 19 | COPY /tsconfig.json ./ 20 | COPY /package.json ./ 21 | RUN npm install 22 | COPY /build.mjs ./ 23 | 24 | COPY /src ./src 25 | RUN npm run build 26 | 27 | FROM node:lts-alpine 28 | 29 | EXPOSE 3000 30 | EXPOSE 8080 31 | 32 | WORKDIR /weather 33 | COPY /package.json ./ 34 | RUN mkdir baselineEToData 35 | COPY --from=build_eto /eto/Baseline_ETo_Data.bin ./baselineEToData 36 | COPY --from=build_node /weather/dist ./dist 37 | 38 | CMD ["npm", "run", "start"] 39 | -------------------------------------------------------------------------------- /scripts/server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * This is used for development only and simply launches a web server and 5 | * injects a script to trigger a reload when a file changes in the serve 6 | * directory. 7 | */ 8 | 9 | const exec = require( "child_process" ).exec; 10 | const path = require( "path" ); 11 | 12 | const routesPath = path.join( __dirname, "../routes/" ); 13 | const serverPath = path.join( __dirname, "../server.ts" ); 14 | 15 | const watch = require( "node-watch" ); 16 | 17 | compile(); 18 | console.log( "OpenSprinkler Development Server Started..." ); 19 | 20 | /** Start the web server */ 21 | exec( `nodemon js/server` ); 22 | 23 | /** Watch for changes and recompile */ 24 | watch( routesPath, { recursive: true }, recompile ); 25 | watch( serverPath, { recursive: true }, recompile ); 26 | 27 | function recompile() { 28 | console.log( "Changes detected, reloading..." ); 29 | compile(); 30 | } 31 | 32 | function compile() { 33 | exec( `npm run compile` ); 34 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Launch via NPM", 8 | "cwd": "${workspaceRoot}", 9 | "runtimeExecutable": "npm", 10 | "windows": { 11 | "runtimeExecutable": "npm.cmd" 12 | }, 13 | "runtimeArgs": [ 14 | "run-script", "debug" 15 | ], 16 | "port": 9229, 17 | "preLaunchTask": "NPM Compile" 18 | }, 19 | { 20 | "type": "node", 21 | "request": "launch", 22 | "name": "Mocha Tests", 23 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 24 | "args": [ 25 | "--require", "ts-node/register", 26 | "-u", "tdd", 27 | "--timeout", "999999", 28 | "--colors", "--recursive", 29 | "${workspaceFolder}/**/*.spec.ts" 30 | ], 31 | "internalConsoleOptions": "openOnSessionStart" 32 | } 33 | 34 | ] 35 | } -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import { isAfter, differenceInMilliseconds } from 'date-fns'; 2 | import { Mutex } from 'async-mutex'; 3 | 4 | export type CachedResult = { 5 | value: T, 6 | ttl: number, 7 | } 8 | 9 | export class Cached { 10 | private mutex: Mutex; 11 | private value: Promise | null = null; 12 | private expiresAt: Date | null = null; 13 | 14 | constructor() { 15 | this.mutex = new Mutex(); 16 | } 17 | 18 | async get(getter: () => Promise, expiresAt: Date): Promise> { 19 | if (this.expiresAt && isAfter(new Date(), this.expiresAt)) { 20 | await this.invalidate(); 21 | } 22 | 23 | const release = await this.mutex.acquire(); 24 | if (!this.value) { 25 | this.value = getter().then((value) => { 26 | this.expiresAt = expiresAt; 27 | return value; 28 | }).catch((err) => { 29 | this.expiresAt = new Date(0); 30 | throw err; 31 | }); 32 | } 33 | 34 | release(); 35 | return { 36 | value: await this.value, 37 | ttl: differenceInMilliseconds(this.expiresAt, new Date()), 38 | }; 39 | } 40 | 41 | async invalidate(): Promise { 42 | this.value = null; 43 | this.expiresAt = null; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/routes/geocoders/WUnderground.ts: -------------------------------------------------------------------------------- 1 | import { GeoCoordinates } from "../../types"; 2 | import { CodedError, ErrorCode } from "../../errors"; 3 | import { httpJSONRequest } from "../weather"; 4 | import { Geocoder } from "./Geocoder"; 5 | 6 | export default class WUndergroundGeocoder extends Geocoder { 7 | public async geocodeLocation( location: string ): Promise { 8 | // Generate URL for autocomplete request 9 | const url = "http://autocomplete.wunderground.com/aq?h=0&query=" + 10 | encodeURIComponent( location ); 11 | 12 | let data; 13 | try { 14 | data = await httpJSONRequest( url ); 15 | } catch ( err ) { 16 | // If the request fails, indicate no data was found. 17 | throw new CodedError( ErrorCode.LocationServiceApiError ); 18 | } 19 | 20 | // Check if the data is valid 21 | if ( typeof data.RESULTS === "object" && data.RESULTS.length && data.RESULTS[ 0 ].tz !== "MISSING" ) { 22 | 23 | // If it is, reply with an array containing the GPS coordinates 24 | return [ parseFloat( data.RESULTS[ 0 ].lat ), parseFloat( data.RESULTS[ 0 ].lon ) ]; 25 | } else { 26 | 27 | // Otherwise, indicate no data was found 28 | throw new CodedError( ErrorCode.NoLocationFound ); 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /src/routes/geocoders/GoogleMaps.ts: -------------------------------------------------------------------------------- 1 | import { GeoCoordinates } from "../../types"; 2 | import { CodedError, ErrorCode } from "../../errors"; 3 | import { httpJSONRequest } from "../weather"; 4 | import { Geocoder } from "./Geocoder"; 5 | 6 | export default class GoogleMapsGeocoder extends Geocoder { 7 | private readonly API_KEY: string; 8 | 9 | public constructor() { 10 | super(); 11 | this.API_KEY = process.env.GOOGLE_MAPS_API_KEY; 12 | if ( !this.API_KEY ) { 13 | throw "GOOGLE_MAPS_API_KEY environment variable is not defined."; 14 | } 15 | } 16 | 17 | public async geocodeLocation( location: string ): Promise { 18 | // Generate URL for Google Maps geocoding request 19 | const url = `https://maps.googleapis.com/maps/api/geocode/json?key=${ this.API_KEY }&address=${ encodeURIComponent( location ) }`; 20 | 21 | let data; 22 | try { 23 | data = await httpJSONRequest( url ); 24 | } catch ( err ) { 25 | // If the request fails, indicate no data was found. 26 | throw new CodedError( ErrorCode.LocationServiceApiError ); 27 | } 28 | 29 | if ( !data.results.length ) { 30 | throw new CodedError( ErrorCode.NoLocationFound ); 31 | } 32 | 33 | return [ data.results[ 0 ].geometry.location.lat, data.results[ 0 ].geometry.location.lng ]; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/routes/adjustmentMethods/RainDelayAdjustmentMethod.ts: -------------------------------------------------------------------------------- 1 | import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod"; 2 | import { GeoCoordinates, PWS, WeatherData } from "../../types"; 3 | import { WeatherProvider } from "../weatherProviders/WeatherProvider"; 4 | 5 | 6 | /** 7 | * Only delays watering if it is currently raining and does not adjust the watering scale. 8 | */ 9 | async function calculateRainDelayWateringScale( 10 | adjustmentOptions: RainDelayAdjustmentOptions, 11 | coordinates: GeoCoordinates, 12 | weatherProvider: WeatherProvider, 13 | pws?: PWS 14 | ): Promise< AdjustmentMethodResponse > { 15 | const data = await weatherProvider.getWeatherData( coordinates, pws ); 16 | const weatherData: WeatherData = data.value; 17 | const raining = weatherData && weatherData.raining; 18 | const d = adjustmentOptions.hasOwnProperty( "d" ) ? adjustmentOptions.d : 24; 19 | return { 20 | scale: undefined, 21 | rawData: { 22 | wp: weatherData.weatherProvider, 23 | raining: raining ? 1 : 0, 24 | }, 25 | rainDelay: raining ? d : undefined, 26 | wateringData: null, 27 | ttl: data.ttl 28 | } 29 | } 30 | 31 | export interface RainDelayAdjustmentOptions extends AdjustmentOptions { 32 | /** The rain delay to use (in hours). */ 33 | d?: number; 34 | } 35 | 36 | 37 | const RainDelayAdjustmentMethod: AdjustmentMethod = { 38 | calculateWateringScale: calculateRainDelayWateringScale 39 | }; 40 | export default RainDelayAdjustmentMethod; 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "os-weather-service", 3 | "description": "OpenSprinkler Weather Service", 4 | "version": "3.0.2", 5 | "repository": "https://github.com/OpenSprinkler/Weather-Weather", 6 | "scripts": { 7 | "test": "mocha --exit --require ts-node/register **/*.spec.ts", 8 | "build": "node build.mjs", 9 | "start": "node dist/index.cjs" 10 | }, 11 | "dependencies": { 12 | "@date-fns/tz": "^1.4.1", 13 | "@types/qs": "^6.14.0", 14 | "async-mutex": "^0.5.0", 15 | "cors": "^2.8.5", 16 | "cron": "^1.8.2", 17 | "date-fns": "^4.1.0", 18 | "dotenv": "^8.6.0", 19 | "express": "^4.21.2", 20 | "geo-tz": "^8.1.4", 21 | "jose": "^6.0.12", 22 | "mockdate": "^2.0.5", 23 | "pino": "^9.9.0", 24 | "pino-http": "^10.5.0", 25 | "qs": "^6.14.0", 26 | "suncalc": "^1.9.0" 27 | }, 28 | "devDependencies": { 29 | "@types/chai": "^4.3.20", 30 | "@types/cors": "^2.8.19", 31 | "@types/cron": "^1.7.3", 32 | "@types/dotenv": "^6.1.1", 33 | "@types/express": "^4.17.23", 34 | "@types/jsonwebtoken": "^9.0.10", 35 | "@types/mocha": "^5.2.7", 36 | "@types/mock-express-request": "^0.2.3", 37 | "@types/nock": "^11.1.0", 38 | "@types/node": "^10.17.60", 39 | "@types/suncalc": "^1.9.2", 40 | "chai": "^4.5.0", 41 | "esbuild": "^0.25.9", 42 | "mocha": "^10.8.2", 43 | "mock-express-request": "^0.2.2", 44 | "mock-express-response": "^0.2.2", 45 | "nock": "^10.0.6", 46 | "node-watch": "^0.6.4", 47 | "nodemon": "^2.0.22", 48 | "ts-node": "^8.10.2", 49 | "typescript": "^5.9.2" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/routes/adjustmentMethods/EToAdjustmentMethod.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { GeoCoordinates, WateringData } from "../../types"; 3 | import { calculateETo } from "./EToAdjustmentMethod"; 4 | import { addDays, fromUnixTime, getUnixTime } from "date-fns"; 5 | 6 | 7 | const testData: TestData[] = require( "../../test/etoTest.json" ); 8 | 9 | describe( "ETo AdjustmentMethod", () => { 10 | describe( "Should correctly calculate ETo", async () => { 11 | for ( const locationData of testData ) { 12 | it( "Using data from " + locationData.description, async () => { 13 | let date = fromUnixTime( locationData.startTimestamp ); 14 | for ( const entry of locationData.entries ) { 15 | const wateringData: WateringData = { 16 | ...entry.data, 17 | precip: 0, 18 | periodStartTime: getUnixTime(date), 19 | weatherProvider: "mock" 20 | }; 21 | const calculatedETo = calculateETo( wateringData, locationData.elevation, locationData.coordinates ); 22 | // Allow a small margin of error for rounding, unit conversions, and approximations. 23 | expect( calculatedETo ).approximately( entry.eto, 0.003 ); 24 | 25 | date = addDays(date, 1); 26 | } 27 | } ); 28 | } 29 | } ); 30 | } ); 31 | 32 | interface TestData { 33 | description: string; 34 | source: string; 35 | startTimestamp: number; 36 | elevation: number; 37 | coordinates: GeoCoordinates; 38 | entries: { 39 | eto: number, 40 | /** This is not actually full WateringData - it is missing `timestamp`, `weatherProvider`, and `precip`. (Hard coded above)*/ 41 | data: WateringData 42 | }[]; 43 | } 44 | -------------------------------------------------------------------------------- /docs/pws-protocol.md: -------------------------------------------------------------------------------- 1 | ## Personal Weather Station Upload Protocol 2 | 3 | **Background** 4 | 5 | To upload a PWS observation, you make a standard HTTP GET request with the weather conditions as the GET parameters. 6 | 7 | **Endpoint** 8 | 9 | The GET message should be directed to the local Weather Service server and with the same endpoint as used by legacy Weather Underground service: 10 | 11 | ``` 12 | http:///weatherstation/updateweatherstation.php 13 | ``` 14 | 15 | **GET Parameters** 16 | 17 | The following fields are required: 18 | 19 | 20 | | Field Name | Format | Description | 21 | |---|:---:|---| 22 | | tempf | 55\.6 | Outdoor temperature in fahrenheit | 23 | | humidity | 0-100 | Outdoor humidity as a percentage | 24 | | rainin | 0.34 | Accumulated rainfall in inches over the last 60 min | 25 | | dailyrainin | 1.45 | Accumulated rainfall in inches for the current day (in local time) | 26 | | dateutc | 2019-03-12 07:45:10 | Time in UTC as YYYY-MM-DD HH:MM:SS (not local time) | 27 | 28 | IMPORTANT all fields must be url escaped. For example, if the current time in utc is "`2019-01-01 10:32:35`" then the dateutc field should be sent as "`2019-01-01+10%3A32%3A35`". For reference see http://www.w3schools.com/tags/ref_urlencode.asp. 29 | 30 | _[To Do: If the weather station is not capable of producing a timestamp then either omit the field or set the field value to "`now`"]_ 31 | 32 | 33 | **Example GET Message** 34 | 35 | Here is an example of a full URL: 36 | ``` 37 | https:///weatherstation/updateweatherstation.php?tempf=70.5&humidity=90&rainin=0&dailyrainin=0.54&dateutc=2000-01-01+10%3A32%3A35 38 | ``` 39 | The response text from the Weather Service server will be either "`success`" or an error message. 40 | 41 | -------------------------------------------------------------------------------- /docs/weewx.md: -------------------------------------------------------------------------------- 1 | ## The WeeWX Project 2 | 3 | **Background** 4 | 5 | From the Author of [WeeWX](http://www.weewx.com) - *"WeeWX is a free, open source, software program, written in Python, which interacts with your weather station to produce graphs, reports, and HTML pages. It can optionally publish to weather sites or web servers. It uses modern software concepts, making it simple, robust, and easy to extend. It includes extensive documentation."* 6 | 7 | **Supported Weather Stations** 8 | 9 | The list of WeeWX supported hardware can be found [here](http://www.weewx.com/hardware.html) along with an extensive installation/configuration documentation [here](http://www.weewx.com/docs.html). There is also an active Google Groups forum [here](https://groups.google.com/forum/#!forum/weewx-user) 10 | 11 | **Connecting WeeWX to OpenSprinkler Weather Service** 12 | 13 | Once installed and capturing data, the WeeWX solution can send the weather observation onto the local Weather Service. WeeWX's built-in Weather Underground plug-in can be configured in the ```/etc/weewx/weewx.conf``` file specifying the IP Address and Port of the local Weather Service server as follows: 14 | 15 | ``` 16 | [[Wunderground]] 17 | enable = true 18 | station = anyText 19 | password = anyText 20 | server_url = http://:/weatherstation/updateweatherstation.php 21 | rapidfire = False 22 | ``` 23 | Note: the `station` and `password` entries are not used by the OS Weather Service but must be populated to keep the plug-in happy. 24 | Second: you can't use the Wunderground upload feature twice in weewx. 25 | 26 | Now you can then restart the WeeWX server and your PWS observations should now be sent to the local Weather Service every 5 minutes. And do not forget to configure opensprinkler weatherservice. Go to the SuperUser Url https:///su and give your host and IP for the weatherservice (https://) 27 | -------------------------------------------------------------------------------- /src/routes/geocoders/Geocoder.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | import { GeoCoordinates } from "../../types"; 5 | import { CodedError, ErrorCode } from "../../errors"; 6 | 7 | export abstract class Geocoder { 8 | 9 | private static cacheFile = process.env.GEOCODER_CACHE_FILE || path.join(__dirname, "..", "geocoderCache.json"); 10 | 11 | private cache: Map; 12 | 13 | public constructor() { 14 | // Load the cache from disk. 15 | if ( fs.existsSync( Geocoder.cacheFile ) ) { 16 | this.cache = new Map( JSON.parse( fs.readFileSync( Geocoder.cacheFile, "utf-8" ) ) ); 17 | } else { 18 | this.cache = new Map(); 19 | } 20 | 21 | // Write the cache to disk every 5 minutes. 22 | setInterval( () => { 23 | this.saveCache(); 24 | }, 5 * 60 * 1000 ); 25 | } 26 | 27 | private saveCache(): void { 28 | fs.writeFileSync( Geocoder.cacheFile, JSON.stringify( Array.from( this.cache.entries() ) ) ); 29 | } 30 | 31 | /** 32 | * Converts a location name to geographic coordinates. 33 | * @param location A location name. 34 | * @return A Promise that will be resolved with the GeoCoordinates of the specified location, or rejected with a 35 | * CodedError. 36 | */ 37 | protected abstract geocodeLocation( location: string ): Promise; 38 | 39 | /** 40 | * Converts a location name to geographic coordinates, first checking the cache and updating it if necessary. 41 | */ 42 | public async getLocation( location: string ): Promise { 43 | if ( this.cache.has( location ) ) { 44 | const coords: GeoCoordinates = this.cache.get( location ); 45 | if ( coords == null ) { 46 | // Throw an error if there are no results for this location. 47 | throw new CodedError( ErrorCode.NoLocationFound ); 48 | } else { 49 | return coords; 50 | } 51 | } 52 | 53 | try { 54 | const coords: GeoCoordinates = await this.geocodeLocation( location ); 55 | this.cache.set( location, coords ); 56 | return coords; 57 | } catch ( ex ) { 58 | if ( ex instanceof CodedError && ex.errCode == ErrorCode.NoLocationFound ) { 59 | // Store in the cache the fact that this location has no results. 60 | this.cache.set( location, null ); 61 | } 62 | 63 | throw ex; 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import './bootstrap'; 3 | 4 | import express from "express"; 5 | import cors from "cors"; 6 | 7 | import { getWateringData, getWeatherData } from "./routes/weather"; 8 | import { captureWUStream } from "./routes/weatherProviders/local"; 9 | import { getBaselineETo } from "./routes/baselineETo"; 10 | import {default as packageJson} from "../package.json"; 11 | import { pinoHttp } from "pino-http"; 12 | import { pino, LevelWithSilent } from "pino"; 13 | 14 | function getLogLevel(): LevelWithSilent { 15 | switch (process.env.LOG_LEVEL) { 16 | case "trace": 17 | return "trace"; 18 | case "debug": 19 | return "debug"; 20 | case "info": 21 | return "info"; 22 | case "warn": 23 | return "warn"; 24 | case "error": 25 | return "error"; 26 | case "fatal": 27 | return "fatal"; 28 | case "silent": 29 | return "silent"; 30 | default: 31 | return "info"; 32 | } 33 | } 34 | 35 | const logger = pino({ level: getLogLevel() }); 36 | 37 | const host = process.env.HOST || "127.0.0.1"; 38 | const port = parseInt(process.env.HTTP_PORT) || 3000; 39 | 40 | export let pws = process.env.PWS || "none"; 41 | export const app = express(); 42 | 43 | // Disable parsing of nested serach queries to make the argument type string | string[] 44 | app.use(express.urlencoded({ extended: false })); 45 | 46 | // Handle requests matching /weatherID.py where ID corresponds to the 47 | // weather adjustment method selector. 48 | // This endpoint is considered deprecated and supported for prior firmware 49 | app.get( /weather(\d+)\.py/, getWateringData ); 50 | app.get( /(\d+)/, getWateringData ); 51 | 52 | // Handle requests matching /weatherData 53 | app.options( /weatherData/, cors() ); 54 | app.get( /weatherData/, cors(), getWeatherData ); 55 | 56 | // Endpoint to stream Weather Underground data from local PWS 57 | if ( pws === "WU" ) { 58 | app.get( "/weatherstation/updateweatherstation.php", captureWUStream ); 59 | } 60 | 61 | app.get( "/", function( req, res ) { 62 | res.send( packageJson.description + " v" + packageJson.version ); 63 | } ); 64 | 65 | // Handle requests matching /baselineETo 66 | app.options( /baselineETo/, cors() ); 67 | app.get( /baselineETo/, cors(), getBaselineETo ); 68 | 69 | // Handle 404 error 70 | app.use( function( req, res ) { 71 | res.status( 404 ); 72 | res.send( "Error: Request not found" ); 73 | } ); 74 | 75 | // Start listening on the service port 76 | app.listen( port, host, function() { 77 | console.log( "%s now listening on %s:%d", packageJson.description, host, port ); 78 | 79 | if (pws !== "none" ) { 80 | console.log( "%s now listening for local weather stream", packageJson.description ); 81 | } 82 | } ); 83 | -------------------------------------------------------------------------------- /docs/davis-vantage.md: -------------------------------------------------------------------------------- 1 | ## Connecting a Davis Vantage PWS to the Local Weather Service 2 | 3 | **Background** 4 | 5 | The Davis Vantage has the option to connect the PWS to a Windows machine using a serial or USB logger. The Davis Weatherlink software has an optional DLL, WUiWlink_1.dll (dated 4/26/17), which is designed to push observation data from the Davis Vantage weather console to WeatherUnderground's now obsolete interface. That feature can be used to push data to a local instance of weather-service, which will, in turn, be accessed by OpenSprinkler as data for the Zimmerman water level calculation. 6 | 7 | Note: if you have the WeatherLinkIP version then see the instructions for using a RaspberryPi Zero to redirect weather data to local weather-service. 8 | 9 | **Configuration** 10 | 11 | To install the DLL module, see the directions at http://www.davisinstruments.com/resource/send-weather-data-weather-underground-weatherlink-software/ 12 | 13 | To redirect the weather data to weather-server, modify the HOSTS file on the Windows machine running Weatherlink by adding the following two lines substituting `` for the IP address of your local Weather Service: 14 | ``` 15 | local rtupdate.wunderground.com 16 | local weatherstation.wunderground.com 17 | ``` 18 | Note: you must be running in administrator mode to make this change. On Windows 10 the HOSTS file is in `C:/Windows/System32/drivers/etc`. The easiest way to do this is to open a Command Prompt in Admin mode, navigate to `C:/Windows/System32/drivers/etc`, then execute "notepad.exe hosts", add the three entries, save, exit, and close the command window. The change should take effect immediately, but you may need to reboot the Windows machine to be sure. 19 | 20 | WARNING (7/30/2020): recent changes to Windows Defender apparently cause the HOSTS file to be cleared. In order to prevent this you must list the HOSTS file to be ignored (Settings|Windows Security|Virus & threat protection|(scroll down)Exclusions|C:\Windows\System32\drivers\etc\hosts) 21 | 22 | In the Weatherlink application you should see "Wunderground settings" in the File menu. You can ignore the StationID and Password settings (or just enter a single blank character). Set the Update Interval to 5 minutes, which should be more than sufficient for the purpose of the Zimmerman water level calculation. 23 | 24 | On the machine running weather-server, edit the weather-server `.env` file to add a line `"PWS=WU"`. Stop and restart weather-service. 25 | 26 | Actual readings from your PWS should now be flowing to weather-service. Make sure you have Zimmerman selected in OpenSprinkler and set the parameters appropriately for your situation. 27 | 28 | **Testing** 29 | 30 | To immediately observe the data feed, open Davis WeatherLink, click on File | Wunderground Settings, then click the "Test" box. 31 | 32 | 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. 33 | -------------------------------------------------------------------------------- /src/routes/adjustmentMethods/AdjustmentMethod.ts: -------------------------------------------------------------------------------- 1 | import { WateringData, GeoCoordinates, PWS } from "../../types"; 2 | import { WeatherProvider } from "../weatherProviders/WeatherProvider"; 3 | 4 | 5 | export interface AdjustmentMethod { 6 | /** 7 | * Calculates the percentage that should be used to scale watering time. 8 | * @param adjustmentOptions The user-specified options for the calculation. No checks will be made to ensure the 9 | * AdjustmentOptions are the correct type that the function is expecting or to ensure that any of its fields are valid. 10 | * @param coordinates The coordinates of the watering site. 11 | * @param weatherProvider The WeatherProvider that should be used if the adjustment method needs to obtain any 12 | * weather data. 13 | * @param pws The PWS to retrieve weather data from, or undefined if a PWS should not be used. If the implementation 14 | * of this method does not have PWS support, this parameter may be ignored and coordinates may be used instead. 15 | * @return A Promise that will be resolved with the result of the calculation, or rejected with an error message if 16 | * the watering scale cannot be calculated. 17 | * @throws A CodedError may be thrown if an error occurs while calculating the watering scale. 18 | */ 19 | calculateWateringScale( 20 | adjustmentOptions: AdjustmentOptions, 21 | coordinates: GeoCoordinates, 22 | weatherProvider: WeatherProvider, 23 | pws?: PWS 24 | ): Promise< AdjustmentMethodResponse >; 25 | } 26 | 27 | export interface AdjustmentMethodResponse { 28 | /** 29 | * The percentage that should be used to scale the watering level. This should be an integer between 0-200 (inclusive), 30 | * or undefined if the watering level should not be changed. 31 | */ 32 | scale: number | undefined; 33 | /** 34 | * The raw data that was used to calculate the watering scale. This will be sent directly to the OS controller, so 35 | * each field should be formatted in a way that the controller understands and numbers should be rounded 36 | * appropriately to remove excessive figures. If no data was used (e.g. an error occurred), this should be undefined. 37 | */ 38 | rawData?: object; 39 | /** 40 | * How long watering should be delayed for (in hours) due to rain, or undefined if watering should not be delayed 41 | * for a specific amount of time (either it should be delayed indefinitely or it should not be delayed at all). This 42 | * property will not stop watering on its own, and the `scale` property should be set to 0 to actually prevent 43 | * watering. 44 | */ 45 | rainDelay?: number; 46 | /** The data that was used to calculate the watering scale, or undefined if no data was used. */ 47 | wateringData: readonly WateringData[]; 48 | /** A list of scales for multiple day data usage. */ 49 | scales?: number[]; 50 | /** How long the data is cached for. */ 51 | ttl: number; 52 | /** Whether watering restriction is active */ 53 | restricted?: number; 54 | } 55 | 56 | export interface AdjustmentOptions { 57 | /** The ID of the PWS to use. */ 58 | pws?: string; 59 | /** The API key to use to access PWS data. */ 60 | key?: string; 61 | /** The provider selected using the UI. */ 62 | provider?: string; 63 | /** Flag used to indicate if historical weather data is used. */ 64 | mda?: number; 65 | /** Flag for the California restriction. */ 66 | cali: boolean; 67 | /** Maximum amount of rain allowed in rain restriction. */ 68 | rainAmt: number; 69 | /** Number of days to check for rain restriction. */ 70 | rainDays: number; 71 | /** Minimum temperature for temp restriction. */ 72 | minTemp: number; 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/routes/weather.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import nock from 'nock'; 3 | import MockExpressRequest from 'mock-express-request'; 4 | import MockExpressResponse from 'mock-express-response'; 5 | import MockDate from 'mockdate'; 6 | 7 | // The tests don't use OWM, but the WeatherProvider API key must be set to prevent an error from being thrown on startup. 8 | process.env.WEATHER_PROVIDER = "OWM"; 9 | process.env.OWM_API_KEY = "NO_KEY"; 10 | 11 | import { getWateringData } from './weather'; 12 | import { GeoCoordinates, WeatherData, WateringData, PWS } from "../types"; 13 | import { WeatherProvider } from "./weatherProviders/WeatherProvider"; 14 | 15 | const expected = require( '../test/expected.json' ); 16 | const replies = require( '../test/replies.json' ); 17 | 18 | const location = '01002'; 19 | 20 | describe('Watering Data', () => { 21 | beforeEach(() => MockDate.set('5/13/2019')); 22 | 23 | it('OpenWeatherMap Lookup (Adjustment Method 0, Location 01002)', async () => { 24 | mockOWM(); 25 | 26 | const expressMocks = createExpressMocks(0, location); 27 | await getWateringData(expressMocks.request, expressMocks.response); 28 | expect( expressMocks.response._getJSON() ).to.eql( expected.noWeather[location] ); 29 | }); 30 | 31 | it('OpenWeatherMap Lookup (Adjustment Method 1, Location 01002)', async () => { 32 | mockOWM(); 33 | 34 | const expressMocks = createExpressMocks(1, location); 35 | await getWateringData(expressMocks.request, expressMocks.response); 36 | expect( expressMocks.response._getJSON() ).to.eql( expected.adjustment1[location] ); 37 | }); 38 | }); 39 | 40 | function createExpressMocks(method: number, location: string) { 41 | const request = new MockExpressRequest({ 42 | method: 'GET', 43 | url: `/${method}?loc=${location}`, 44 | query: { 45 | loc: location, 46 | format: 'json' 47 | }, 48 | params: [ method ], 49 | headers: { 50 | 'x-forwarded-for': '127.0.0.1' 51 | } 52 | }); 53 | 54 | return { 55 | request, 56 | response: new MockExpressResponse({ 57 | request 58 | }) 59 | } 60 | } 61 | 62 | function mockOWM() { 63 | nock( 'http://api.openweathermap.org' ) 64 | .filteringPath( function() { return "/"; } ) 65 | .get( "/" ) 66 | .reply( 200, replies[location].OWMData ); 67 | } 68 | 69 | 70 | /** 71 | * A WeatherProvider for testing purposes that returns weather data that is provided in the constructor. 72 | * This is a special WeatherProvider designed for testing purposes and should not be activated using the 73 | * WEATHER_PROVIDER environment variable. 74 | */ 75 | export class MockWeatherProvider extends WeatherProvider { 76 | 77 | private readonly mockData: MockWeatherData; 78 | 79 | public constructor(mockData: MockWeatherData) { 80 | super(); 81 | this.mockData = mockData; 82 | } 83 | 84 | protected async getWateringDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WateringData[] > { 85 | return await this.getData( "wateringData" ) as WateringData[]; 86 | } 87 | 88 | protected async getWeatherDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WeatherData > { 89 | return await this.getData( "weatherData" ) as WeatherData; 90 | } 91 | 92 | private async getData( type: "wateringData" | "weatherData" ) { 93 | const data = this.mockData[ type ]; 94 | if (data instanceof Array) { 95 | data.forEach((e) => { 96 | if ( !e.weatherProvider ) { 97 | e.weatherProvider = "mock"; 98 | } 99 | }); 100 | } else { 101 | if ( !data.weatherProvider ) { 102 | data.weatherProvider = "mock"; 103 | } 104 | } 105 | 106 | return data; 107 | } 108 | } 109 | 110 | interface MockWeatherData { 111 | wateringData?: WateringData[], 112 | weatherData?: WeatherData 113 | } 114 | -------------------------------------------------------------------------------- /docs/netatmo.md: -------------------------------------------------------------------------------- 1 | ## Setup a Netatmo PWS to stream data to a local Weather Service 2 | 3 | **Background** 4 | 5 | Netatmo Weather Stations send weather information to the Netatmo Cloud using an encrypted data stream. So for this PWS, we cannot readily intercept the data before it leaves the home network. Instead, we can make use of a WeeWX plug-in that can retreive the weather data from the Netatmo Cloud and then forward it onto our local Weather Service. 6 | 7 | **Step 1: Install the local Weather Service and WeeWX solutions** 8 | 9 | * **Step 1a:** Install your local Weather Service and configure the service for PWS input using the instructions [here](local-installation.md). 10 | 11 | * **Step 1b:** Next, install the WeeWX platofrm using the instructions [here](weewx.md) 12 | 13 | Note that you can install both the local Weather Service and the WeeWX solution onto the same Raspberry Pi if you wish. 14 | 15 | **Step 3: Register a Client ID on the Netatmo Cloud** 16 | 17 | Now we need to register with the Netatmo Cloud service in order to obtain a `Client ID` and a `Client Secret` via the `netatmo.com` web site. 18 | 19 | During registration, you will need to create a Netatmo Connect project and an APP in addition to the username and password already available for the existing Netatmo account: 20 | 21 | * Whilst creating the APP, a form will be presented requesting a number of items. As a minimum, you need to provide: Name; Description; Data Protection Officer Name; and Data Protection Officer Email. 22 | 23 | * Note that the Email Address should be the same as used to access the Netatmo account, all the other text may vary. 24 | 25 | * After saving this form the so called “Technical Parameters” Client id and Client secret can be obtained. 26 | 27 | These credentials, together with the username (Email Address) and password, are needed to install the WeeWX Netatmo plug-in. 28 | 29 | **Step 4: Install and Configure the WeeWX Netatmo Plug-In** 30 | 31 | There is a Netatmo/WeeWX driver, written by Matthew Wall, that can be added to the WeeWX platform in order to retreive weather data from the Netatmo Cloud service. The procedure to install the plug-in as avaliable [here](https://github.com/matthewwall/weewx-netatmo) 32 | 33 | Once installed, confirm that the necessary configuration has been added to the `/etc/weewx/weewx.conf` file. The file should be set to select the `netatmo` station type and to provide your account information as follows: 34 | 35 | ``` 36 | # in this file with a ‘driver’ parameter indicating the driver to be used. 37 | station_type = netatmo 38 | ... 39 | ############################################################################## 40 | [netatmo] 41 | username = 42 | client_secret = 43 | password = 44 | driver = user.netatmo 45 | client_id = 46 | mode = cloud 47 | ############################################################################## 48 | ``` 49 | 50 | **Step 4: Configure WeeWX to forward Weather Data to you local Weather Service** 51 | 52 | Once installed and capturing data, the WeeWX solution can send the Netatmo weather observation onto the local Weather Service. WeeWX's built-in Weather Underground plug-in can be configured in the ```/etc/weewx/weewx.conf``` file specifying the IP Address and Port of the local Weather Service server as follows: 53 | 54 | ``` 55 | [[Wunderground]] 56 | enable = true 57 | station = anyText 58 | password = anyText 59 | server_url = http://:/weatherstation/updateweatherstation.php 60 | rapidfire = False 61 | ``` 62 | Note: the `station` and `password` entries are not used by the OS Weather Service but must be populated to keep the plug-in happy. 63 | 64 | You should now `stop` and `start` the WeeWX service for the configuration options to take effect: 65 | 66 | ``` 67 | $ sudo /etc/init.d/weewx stop 68 | $ sudo /etc/init.d/weewx start 69 | ``` 70 | Your Netatmo weather information should now be sent to the local Weather Service every 5 minutes. 71 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | export enum ErrorCode { 2 | /** No error occurred. This code should be included with all successful responses because the firmware expects some 3 | * code to be present. 4 | */ 5 | NoError = 0, 6 | 7 | /** The watering scale could not be calculated due to a problem with the weather information. */ 8 | BadWeatherData = 1, 9 | /** Data for a full 24 hour period was not available. */ 10 | InsufficientWeatherData = 10, 11 | /** A necessary field was missing from weather data returned by the API. */ 12 | MissingWeatherField = 11, 13 | /** An HTTP or parsing error occurred when retrieving weather information. */ 14 | WeatherApiError = 12, 15 | 16 | /** The specified location name could not be resolved. */ 17 | LocationError = 2, 18 | /** An HTTP or parsing error occurred when resolving the location. */ 19 | LocationServiceApiError = 20, 20 | /** No matches were found for the specified location name. */ 21 | NoLocationFound = 21, 22 | /** The location name was specified in an invalid format (e.g. a PWS ID). */ 23 | InvalidLocationFormat = 22, 24 | 25 | /** An Error related to personal weather stations. */ 26 | PwsError = 3, 27 | /** The PWS ID did not use the correct format. */ 28 | InvalidPwsId = 30, 29 | /** The PWS API key did not use the correct format. */ 30 | InvalidPwsApiKey = 31, 31 | // TODO use this error code. 32 | /** The PWS API returned an error because a bad API key was specified. */ 33 | PwsAuthenticationError = 32, 34 | /** A PWS was specified but the data for the specified AdjustmentMethod cannot be retrieved from a PWS. */ 35 | PwsNotSupported = 33, 36 | /** A PWS is required by the WeatherProvider but was not provided. */ 37 | NoPwsProvided = 34, 38 | 39 | /** API key is not provided to a weather service provider that requires API key */ 40 | NoAPIKeyProvided = 35, 41 | 42 | /** An error related to AdjustmentMethods or watering restrictions. */ 43 | AdjustmentMethodError = 4, 44 | /** The WeatherProvider is incompatible with the specified AdjustmentMethod. */ 45 | UnsupportedAdjustmentMethod = 40, 46 | /** An invalid AdjustmentMethod ID was specified. */ 47 | InvalidAdjustmentMethod = 41, 48 | 49 | /** An error related to adjustment options (wto). */ 50 | AdjustmentOptionsError = 5, 51 | /** The adjustment options could not be parsed. */ 52 | MalformedAdjustmentOptions = 50, 53 | /** A required adjustment option was not provided. */ 54 | MissingAdjustmentOption = 51, 55 | 56 | /** An error was not properly handled and assigned a more specific error code. */ 57 | UnexpectedError = 99 58 | } 59 | 60 | /** An error with a numeric code that can be used to identify the type of error. */ 61 | export class CodedError extends Error { 62 | public readonly errCode: ErrorCode; 63 | 64 | public constructor( errCode: ErrorCode, message?: string ) { 65 | super( message ); 66 | // https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work 67 | Object.setPrototypeOf( this, CodedError.prototype ); 68 | this.errCode = errCode; 69 | } 70 | } 71 | 72 | /** 73 | * Returns a CodedError representing the specified error. This function can be used to ensure that errors caught in try-catch 74 | * statements have an error code and do not contain any sensitive information in the error message. If `err` is a 75 | * CodedError, the same object will be returned. If `err` is not a CodedError, it is assumed that the error wasn't 76 | * properly handled, so a CodedError with a generic message and an "UnexpectedError" code will be returned. This ensures 77 | * that the user will only be sent errors that were initially raised by the OpenSprinkler weather service and have 78 | * had any sensitive information (like API keys) removed from the error message. 79 | * @param err Any error caught in a try-catch statement. 80 | * @return A CodedError representing the error that was passed to the function. 81 | */ 82 | export function makeCodedError( err: any ): CodedError { 83 | if ( err instanceof CodedError ) { 84 | return err; 85 | } else { 86 | console.error("Unexpected error:", err); 87 | return new CodedError( ErrorCode.UnexpectedError ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** Geographic coordinates. The 1st element is the latitude, and the 2nd element is the longitude. */ 2 | export type GeoCoordinates = [number, number]; 3 | 4 | /** A PWS ID and API key. */ 5 | export type PWS = { id?: string, apiKey: string }; 6 | 7 | export interface TimeData { 8 | /** The UTC offset, in minutes. This uses POSIX offsets, which are the negation of typically used offsets 9 | * (https://github.com/eggert/tz/blob/2017b/etcetera#L36-L42). 10 | */ 11 | timezone: number; 12 | /** The time of sunrise, in minutes from UTC midnight. */ 13 | sunrise: number; 14 | /** The time of sunset, in minutes from UTC midnight. */ 15 | sunset: number; 16 | } 17 | 18 | export interface WeatherData { 19 | /** The WeatherProvider that generated this data. */ 20 | weatherProvider: WeatherProviderId; 21 | /** The current temperature (in Fahrenheit). */ 22 | temp: number; 23 | /** The current humidity (as a percentage). */ 24 | humidity: number; 25 | /** The current wind speed (in miles per hour). */ 26 | wind: number; 27 | /** A flag if it is currently raining. */ 28 | raining: boolean; 29 | /** A human-readable description of the weather. */ 30 | description: string; 31 | /** An icon ID that represents the current weather. This will be used in http://openweathermap.org/img/w/.png */ 32 | icon: string; 33 | region: string; 34 | city: string; 35 | /** The forecasted minimum temperature for the current day (in Fahrenheit). */ 36 | minTemp: number; 37 | /** The forecasted minimum temperature for the current day (in Fahrenheit). */ 38 | maxTemp: number; 39 | /** The forecasted total precipitation for the current day (in inches). */ 40 | precip: number; 41 | forecast: WeatherDataForecast[] 42 | } 43 | 44 | /** The forecasted weather for a specific day in the future. */ 45 | export interface WeatherDataForecast { 46 | /** The forecasted minimum temperature for this day (in Fahrenheit). */ 47 | temp_min: number; 48 | /** The forecasted maximum temperature for this day (in Fahrenheit). */ 49 | temp_max: number; 50 | /** The forecaseted precipitation for this day (in inches). */ 51 | precip: number; 52 | /** The timestamp of the day this forecast is for (in Unix epoch seconds). */ 53 | date: number; 54 | /** An icon ID that represents the weather at this forecast window. This will be used in http://openweathermap.org/img/w/.png */ 55 | icon: string; 56 | /** A human-readable description of the weather. */ 57 | description: string; 58 | } 59 | 60 | /** 61 | * Data from a set of 24 hour windows that is used to calculate how watering levels should be scaled. This should ideally use 62 | * as many days of historic data as possible based on the selected provider. 63 | */ 64 | 65 | export interface WateringData { 66 | /** The WeatherProvider that generated this data. */ 67 | weatherProvider: WeatherProviderShortId; 68 | /** The total precipitation over the window (in inches). */ 69 | precip: number; 70 | /** The average temperature over the window (in Fahrenheit). */ 71 | temp: number; 72 | /** The average humidity over the window (as a percentage). */ 73 | humidity: number; 74 | /** The Unix epoch seconds timestamp of the start of this 24 hour time window. */ 75 | periodStartTime: number; 76 | /** The minimum temperature over the time period (in Fahrenheit). */ 77 | minTemp: number; 78 | /** The maximum temperature over the time period (in Fahrenheit). */ 79 | maxTemp: number; 80 | /** The minimum relative humidity over the time period (as a percentage). */ 81 | minHumidity: number; 82 | /** The maximum relative humidity over the time period (as a percentage). */ 83 | maxHumidity: number; 84 | /** The solar radiation, accounting for cloud coverage (in kilowatt hours per square meter per day). */ 85 | solarRadiation: number; 86 | /** 87 | * The average wind speed measured at 2 meters over the time period (in miles per hour). A measurement taken at a 88 | * different height can be standardized to 2m using the `standardizeWindSpeed` function in EToAdjustmentMethod. 89 | */ 90 | windSpeed: number; 91 | } 92 | 93 | export type WeatherProviderId = "OWM" | "PirateWeather" | "local" | "mock" | "WUnderground" | "DWD" | "OpenMeteo" | "AccuWeather" | "Apple"; 94 | export type WeatherProviderShortId = "OWM" | "PW" | "local" | "mock" | "WU" | "DWD" | "OpenMeteo" | "AW" | "Apple"; 95 | -------------------------------------------------------------------------------- /src/routes/adjustmentMethods/ZimmermanAdjustmentMethod.ts: -------------------------------------------------------------------------------- 1 | import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod"; 2 | import { GeoCoordinates, PWS, WateringData } from "../../types"; 3 | import { validateValues } from "../weather"; 4 | import { WeatherProvider } from "../weatherProviders/WeatherProvider"; 5 | import { CodedError, ErrorCode } from "../../errors"; 6 | 7 | 8 | /** 9 | * Calculates how much watering should be scaled based on weather and adjustment options using the Zimmerman method. 10 | * (https://github.com/rszimm/sprinklers_pi/wiki/Weather-adjustments#formula-for-setting-the-scale) 11 | */ 12 | async function calculateZimmermanWateringScale( 13 | adjustmentOptions: ZimmermanAdjustmentOptions, 14 | coordinates: GeoCoordinates, 15 | weatherProvider: WeatherProvider, 16 | pws?: PWS 17 | ): Promise< AdjustmentMethodResponse > { 18 | const data = await weatherProvider.getWateringData( coordinates, pws ); 19 | const wateringData: readonly WateringData[] = data.value; 20 | 21 | // Map data into proper format 22 | const rawData = wateringData.map(data => { 23 | return { 24 | wp: data.weatherProvider, 25 | h: data ? Math.round( data.humidity * 100) / 100 : null, 26 | p: data ? Math.round( data.precip * 100 ) / 100 : null, 27 | t: data ? Math.round( data.temp * 10 ) / 10 : null, 28 | }; 29 | }); 30 | 31 | for ( let i = 0; i < wateringData.length; i++ ) { 32 | // Check to make sure valid data exists for all factors 33 | if ( !validateValues( [ "temp", "humidity", "precip" ], wateringData[i] ) ) { 34 | // Default to a scale of 100% if fields are missing. 35 | throw new CodedError( ErrorCode.MissingWeatherField ); 36 | } 37 | } 38 | 39 | let humidityBase = 30, tempBase = 70, precipBase = 0; 40 | 41 | // Get baseline conditions for 100% water level, if provided 42 | humidityBase = adjustmentOptions.hasOwnProperty( "bh" ) ? adjustmentOptions.bh : humidityBase; 43 | tempBase = adjustmentOptions.hasOwnProperty( "bt" ) ? adjustmentOptions.bt : tempBase; 44 | precipBase = adjustmentOptions.hasOwnProperty( "br" ) ? adjustmentOptions.br : precipBase; 45 | 46 | // Compute uncapped scales for each day 47 | const uncappedScales = wateringData.map(data => { 48 | let humidityFactor = ( humidityBase - data.humidity ), 49 | tempFactor = ( ( data.temp - tempBase ) * 4 ), 50 | precipFactor = ( ( precipBase - data.precip ) * 200 ); 51 | 52 | // Apply adjustment options, if provided, by multiplying the percentage against the factor 53 | if ( adjustmentOptions.hasOwnProperty( "h" ) ) { 54 | humidityFactor = humidityFactor * ( adjustmentOptions.h / 100 ); 55 | } 56 | 57 | if ( adjustmentOptions.hasOwnProperty( "t" ) ) { 58 | tempFactor = tempFactor * ( adjustmentOptions.t / 100 ); 59 | } 60 | 61 | if ( adjustmentOptions.hasOwnProperty( "r" ) ) { 62 | precipFactor = precipFactor * ( adjustmentOptions.r / 100 ); 63 | } 64 | 65 | return 100 + humidityFactor + tempFactor + precipFactor; 66 | }); 67 | 68 | // Compute a rolling average for each scale and cap them to 0-200 69 | let sum = 0; 70 | let count = 1; 71 | const scales = uncappedScales.map(scale => { 72 | sum += scale; 73 | const result = Math.floor( Math.min( Math.max( 0, sum / count ), 200 ) ); 74 | count ++; 75 | return result; 76 | }); 77 | 78 | return { 79 | // Apply all of the weather modifying factors and clamp the result between 0 and 200%. 80 | scale: scales[0], 81 | rawData: rawData[0], 82 | wateringData: wateringData, 83 | scales: scales, 84 | ttl: data.ttl, 85 | } 86 | } 87 | 88 | export interface ZimmermanAdjustmentOptions extends AdjustmentOptions { 89 | /** Base humidity (as a percentage). */ 90 | bh?: number; 91 | /** Base temperature (in Fahrenheit). */ 92 | bt?: number; 93 | /** Base precipitation (in inches). */ 94 | br?: number; 95 | /** The percentage to weight the humidity factor by. */ 96 | h?: number; 97 | /** The percentage to weight the temperature factor by. */ 98 | t?: number; 99 | /** The percentage to weight the precipitation factor by. */ 100 | r?: number; 101 | } 102 | 103 | 104 | const ZimmermanAdjustmentMethod: AdjustmentMethod = { 105 | calculateWateringScale: calculateZimmermanWateringScale 106 | }; 107 | export default ZimmermanAdjustmentMethod; 108 | -------------------------------------------------------------------------------- /src/routes/weatherProviders/WeatherProvider.ts: -------------------------------------------------------------------------------- 1 | import * as geoTZ from "geo-tz"; 2 | import { TZDate } from "@date-fns/tz"; 3 | import { GeoCoordinates, PWS, WeatherData, WateringData } from "../../types"; 4 | import { CodedError, ErrorCode } from "../../errors"; 5 | import { Cached, CachedResult } from "../../cache"; 6 | import { httpJSONRequest } from "../weather"; 7 | import { addDays, addHours, endOfDay, startOfDay } from "date-fns"; 8 | 9 | export class WeatherProvider { 10 | /** 11 | * Retrieves weather data necessary for watering level calculations. 12 | * @param coordinates The coordinates to retrieve the watering data for. 13 | * @param pws The PWS to retrieve the weather from, or undefined if a PWS should not be used. If the implementation 14 | * of this method does not have PWS support, this parameter may be ignored and coordinates may be used instead. 15 | * @return A Promise that will be resolved with the WateringData if it is successfully retrieved, 16 | * or rejected with a CodedError if an error occurs while retrieving the WateringData (or the WeatherProvider 17 | * does not support this method). 18 | */ 19 | getWateringData( coordinates: GeoCoordinates, pws?: PWS ): Promise< CachedResult > { 20 | const key = this.getCacheKey(coordinates, pws); 21 | if (!this.wateringDataCache[key]) { 22 | this.wateringDataCache[key] = new Cached(); 23 | } 24 | 25 | let tz = geoTZ.find(coordinates[0], coordinates[1])[0]; 26 | 27 | const expiresAt = addDays(startOfDay(TZDate.tz(tz)), 1); 28 | 29 | return this.wateringDataCache[key].get(() => this.getWateringDataInternal(coordinates, pws), expiresAt); 30 | } 31 | 32 | /** 33 | * Retrieves the current weather data for usage in the mobile app. 34 | * @param coordinates The coordinates to retrieve the weather for 35 | * @return A Promise that will be resolved with the WeatherData if it is successfully retrieved, 36 | * or rejected with an error message if an error occurs while retrieving the WeatherData or the WeatherProvider does 37 | * not support this method. 38 | */ 39 | getWeatherData( coordinates : GeoCoordinates, pws?: PWS ): Promise< CachedResult > { 40 | const key = this.getCacheKey(coordinates, pws); 41 | if (!this.weatherDataCache[key]) { 42 | this.weatherDataCache[key] = new Cached(); 43 | } 44 | 45 | let tz = geoTZ.find(coordinates[0], coordinates[1])[0]; 46 | 47 | const date = TZDate.tz(tz); 48 | const expiresAt = addHours(startOfDay(date), (Math.floor(date.getHours() / 6) + 1) * 6); 49 | 50 | return this.weatherDataCache[key].get(() => this.getWeatherDataInternal(coordinates, pws), expiresAt); 51 | } 52 | 53 | /** 54 | * Returns a boolean indicating if watering scales calculated using data from this WeatherProvider should be cached 55 | * until the end of the day in timezone the data was for. 56 | * @return a boolean indicating if watering scales calculated using data from this WeatherProvider should be cached. 57 | */ 58 | shouldCacheWateringScale(): boolean { 59 | return false; 60 | } 61 | 62 | private wateringDataCache: {[key: string]: Cached} = {}; 63 | private weatherDataCache: {[key: string]: Cached} = {}; 64 | 65 | private getCacheKey(coordinates: GeoCoordinates, pws?: PWS): string { 66 | return pws?.id || `${coordinates[0]};s${coordinates[1]}` 67 | } 68 | 69 | /** 70 | * Internal command to get the weather data from an API, will be cached when anything outside calls it 71 | * @param coordinates Coordinates of requested data 72 | * @param pws PWS data which includes the apikey 73 | * @returns Returns weather data (should not be mutated) 74 | */ 75 | protected async getWeatherDataInternal(coordinates: GeoCoordinates, pws: PWS | undefined): Promise { 76 | throw "Selected WeatherProvider does not support getWeatherData"; 77 | } 78 | 79 | /** 80 | * Internal command to get the watering data from an API, will be cached when anything outside calls it 81 | * @param coordinates Coordinates of requested data 82 | * @param pws PWS data which includes the apikey 83 | * @returns Returns watering data array in reverse chronological order (array should not be mutated) 84 | */ 85 | protected async getWateringDataInternal(coordinates: GeoCoordinates, pws: PWS | undefined): Promise { 86 | throw new CodedError( ErrorCode.UnsupportedAdjustmentMethod ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/test/etoTest.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "Badgerys Creek, AU for May 2019", 4 | "source": "http://www.bom.gov.au/watl/eto/tables/nsw/badgerys_creek/badgerys_creek-201905.csv", 5 | "elevation": 266, 6 | "coordinates": [ -33.90, 150.73 ], 7 | "startTimestamp": 1556668800, 8 | "entries": [ 9 | {"eto":0.075,"data":{"maxTemp":76.46,"minTemp":55.04,"maxHumidity":100,"minHumidity":58,"windSpeed":2.309,"solarRadiation":2.889}}, 10 | {"eto":0.063,"data":{"maxTemp":77,"minTemp":56.84,"maxHumidity":100,"minHumidity":63,"windSpeed":1.707,"solarRadiation":2.406}}, 11 | {"eto":0.035,"data":{"maxTemp":68.36,"minTemp":56.84,"maxHumidity":100,"minHumidity":91,"windSpeed":2.309,"solarRadiation":1.186}}, 12 | {"eto":0.11,"data":{"maxTemp":72.86,"minTemp":58.46,"maxHumidity":100,"minHumidity":36,"windSpeed":5.254,"solarRadiation":3.375}}, 13 | {"eto":0.098,"data":{"maxTemp":69.44,"minTemp":48.56,"maxHumidity":96,"minHumidity":46,"windSpeed":6.324,"solarRadiation":2.947}}, 14 | {"eto":0.098,"data":{"maxTemp":70.16,"minTemp":47.84,"maxHumidity":97,"minHumidity":39,"windSpeed":4.551,"solarRadiation":3.8}}, 15 | {"eto":0.075,"data":{"maxTemp":71.42,"minTemp":39.74,"maxHumidity":100,"minHumidity":37,"windSpeed":2.259,"solarRadiation":3.767}}, 16 | {"eto":0.114,"data":{"maxTemp":68.36,"minTemp":41.36,"maxHumidity":99,"minHumidity":34,"windSpeed":6.676,"solarRadiation":3.6}}, 17 | {"eto":0.063,"data":{"maxTemp":68.72,"minTemp":36.32,"maxHumidity":99,"minHumidity":36,"windSpeed":1.673,"solarRadiation":3.65}}, 18 | {"eto":0.071,"data":{"maxTemp":65.66,"minTemp":41.18,"maxHumidity":100,"minHumidity":43,"windSpeed":3.999,"solarRadiation":1.878}}, 19 | {"eto":0.13,"data":{"maxTemp":69.08,"minTemp":42.08,"maxHumidity":78,"minHumidity":38,"windSpeed":7.88,"solarRadiation":3.608}}, 20 | {"eto":0.071,"data":{"maxTemp":71.6,"minTemp":38.48,"maxHumidity":99,"minHumidity":35,"windSpeed":2.158,"solarRadiation":3.606}}, 21 | {"eto":0.067,"data":{"maxTemp":73.04,"minTemp":38.84,"maxHumidity":100,"minHumidity":51,"windSpeed":2.326,"solarRadiation":3.469}}, 22 | {"eto":0.079,"data":{"maxTemp":75.74,"minTemp":43.52,"maxHumidity":100,"minHumidity":33,"windSpeed":2.242,"solarRadiation":3.542}}, 23 | {"eto":0.067,"data":{"maxTemp":72.68,"minTemp":44.42,"maxHumidity":100,"minHumidity":45,"windSpeed":1.991,"solarRadiation":3.506}}, 24 | {"eto":0.067,"data":{"maxTemp":71.6,"minTemp":44.06,"maxHumidity":100,"minHumidity":47,"windSpeed":2.326,"solarRadiation":3.464}}, 25 | {"eto":0.071,"data":{"maxTemp":73.94,"minTemp":43.16,"maxHumidity":100,"minHumidity":45,"windSpeed":2.393,"solarRadiation":3.411}}, 26 | {"eto":0.071,"data":{"maxTemp":73.4,"minTemp":45.5,"maxHumidity":100,"minHumidity":50,"windSpeed":2.56,"solarRadiation":3.417}}, 27 | {"eto":0.063,"data":{"maxTemp":73.22,"minTemp":51.44,"maxHumidity":100,"minHumidity":51,"windSpeed":2.342,"solarRadiation":2.783}}, 28 | {"eto":0.055,"data":{"maxTemp":74.12,"minTemp":46.58,"maxHumidity":100,"minHumidity":51,"windSpeed":1.69,"solarRadiation":2.706}}, 29 | {"eto":0.067,"data":{"maxTemp":78.44,"minTemp":44.06,"maxHumidity":100,"minHumidity":43,"windSpeed":1.723,"solarRadiation":3.289}}, 30 | {"eto":0.071,"data":{"maxTemp":77.36,"minTemp":47.3,"maxHumidity":100,"minHumidity":40,"windSpeed":2.125,"solarRadiation":3.267}}, 31 | {"eto":0.063,"data":{"maxTemp":74.48,"minTemp":53.06,"maxHumidity":100,"minHumidity":53,"windSpeed":1.991,"solarRadiation":3.175}}, 32 | {"eto":0.059,"data":{"maxTemp":73.58,"minTemp":44.42,"maxHumidity":100,"minHumidity":48,"windSpeed":2.008,"solarRadiation":3.108}}, 33 | {"eto":0.087,"data":{"maxTemp":77.9,"minTemp":42.8,"maxHumidity":100,"minHumidity":26,"windSpeed":2.828,"solarRadiation":3.272}}, 34 | {"eto":0.091,"data":{"maxTemp":72.68,"minTemp":44.24,"maxHumidity":92,"minHumidity":29,"windSpeed":3.865,"solarRadiation":2.747}}, 35 | {"eto":0.13,"data":{"maxTemp":66.02,"minTemp":39.74,"maxHumidity":82,"minHumidity":35,"windSpeed":9.905,"solarRadiation":2.425}}, 36 | {"eto":0.106,"data":{"maxTemp":65.66,"minTemp":37.58,"maxHumidity":69,"minHumidity":31,"windSpeed":5.739,"solarRadiation":3.211}}, 37 | {"eto":0.161,"data":{"maxTemp":65.48,"minTemp":47.66,"maxHumidity":52,"minHumidity":31,"windSpeed":10.859,"solarRadiation":2.997}}, 38 | {"eto":0.102,"data":{"maxTemp":60.08,"minTemp":36.68,"maxHumidity":70,"minHumidity":31,"windSpeed":6.743,"solarRadiation":3.172}}, 39 | {"eto":0.087,"data":{"maxTemp":68,"minTemp":34.34,"maxHumidity":82,"minHumidity":34,"windSpeed":4.149,"solarRadiation":3.15}} 40 | ] 41 | } 42 | ] 43 | -------------------------------------------------------------------------------- /baselineEToData/README.md: -------------------------------------------------------------------------------- 1 | # Baseline ETo Data 2 | 3 | The baseline ETo endpoint determines the baseline ETo for a location by reading a file generated using data from [MOD16](https://www.ntsg.umt.edu/project/modis/mod16.php). 4 | The data is stored in a binary file that has 4 key differences from the GeoTIFF provided by MOD16: 5 | * The bit depth is decreased from 16 bits to 8 bits to reduce the file size. 6 | * Missing data is interpolated using the values of surrounding pixels. 7 | The MOD16 dataset does not contain data for locations that don't have vegetated land cover (such as urban environments), which can be problematic since many users may set their location to nearby cities. 8 | * The data is stored in an uncompressed format so that geographic coordinates can be mapped to the offset of the corresponding pixel in the file. 9 | This means the file can be stored on disk instead of memory, and the pixel for a specified location can be quickly accessed by seeking to the calculated offset in the file. 10 | * A metadata header that contains parameters about the data used to create the file (such as the image dimensions and instructions on how to map a pixel value to an annual ETo value) is added to the beginning of the file. 11 | This header enables the weather server to use datafiles generated from future versions of the MOD16 dataset (even if these versions modify some of these parameters). 12 | 13 | The datafile is to be stored as `baselineEToData/Baseline_ETo_Data.bin`. 14 | The datafile is not included in the repo because it is very large (62 MB zipped, 710 MB uncompressed), but it [can be downloaded separately](http://www.mediafire.com/file/n7z32dbdvgyupk3/Baseline_ETo_Data.zip/file). 15 | This file was generated by making 20 [passes](#passes) over the data from 2000-2013 in the MOD16A3 dataset. 16 | Alternatively, it can be generated by running the data preparer program yourself. 17 | 18 | ## Preparing the Datafile 19 | 20 | Since TIFF files do not support streaming, directly using the GeoTIFF images from MOD16 would require loading the entire image into memory. 21 | To avoid this, the file must first be converted to a binary format so the pixels in the image can be read row-by-row. 22 | Running `./prepareData.sh ` will download the required image files using [wget](https://www.gnu.org/software/wget/), convert them to a binary format using [ImageMagick](https://imagemagick.org/index.php), compile the program with [gcc](https://gcc.gnu.org/), and run it . 23 | This process can be simplified by using the included Dockerfile that will perform all of these steps inside a container. 24 | The Dockerfile can be used by running `docker build -t baseline-eto-data-preparer . && docker run --rm -v $(pwd):/output baseline-eto-data-preparer `. 25 | 26 | The `` argument is used to control how much the program should attempt to fill in missing data. 27 | 28 | (#passes) 29 | ### Passes 30 | The program fills in missing data by making several successive passes over the entire image, attempting to fill in each missing pixel on each pass. 31 | The value for each missing pixel is interpolated using the values of pixels in the surrounding 5x5 square, and missing pixels that don't have enough data available will be skipped. 32 | However, these pixels may be filled in on a later pass if future passes are able to fill in the surrounding pixels. 33 | Running the program with a higher number of passes will fill in more missing data, but the program will take longer to run and each subsequent pass becomes less accurate (since the interpolations will be based on interpolated data). 34 | 35 | ## File Format 36 | 37 | The data will be saved in a binary format beginning with the a 32 byte big-endian header in the following format: 38 | 39 | | Offset | Type | Description | 40 | | --- | --- | --- | 41 | | 0 | uint8 | File format version | 42 | | 1-4 | uint32 | Image width (in pixels) | 43 | | 5-8 | uint32 | Image height (in pixels) | 44 | | 9 | uint8 | Pixel bit depth (the only bit depth currently supported is 8) | 45 | | 10-13 | float | Minimum ETo | 46 | | 14-17 | float | Scaling factor | 47 | | 18-32 | N/A | May be used in future versions | 48 | 49 | The header is immediately followed by a `IMAGE_WIDTH * IMAGE_HEIGHT` bytes of data corresponding to the pixels in the image in row-major order. 50 | Each pixel is interpreted as an 8 bit unsigned integer, and the average annual potential ETo at that location is `PIXEL * SCALING_FACTOR + MINIMUM_ETO` inches/year. 51 | A value of `255` is special and indicates that no data is available for that location. 52 | 53 | ## Notes 54 | 55 | * Although the [MOD16 documentation]((http://files.ntsg.umt.edu/data/NTSG_Products/MOD16/MOD16UsersGuide_V1.6_2018Aug.docx)) states that several pixel values are used to indicate the land cover type for locations that are missing data, the image actually only uses the value `65535`. 56 | The program handles this by using a [mask image of water bodies](https://static1.squarespace.com/static/58586fa5ebbd1a60e7d76d3e/t/59394abb37c58179160775fa/1496926933082/Ocean_Mask.png) so it can fill in pixels for urban environments without also filling in data for oceans. 57 | * The map uses an [equirectangular projection](https://en.wikipedia.org/wiki/Equirectangular_projection) with the northernmost 10 degrees and southernmost 30 degrees cropped off. 58 | -------------------------------------------------------------------------------- /docs/man-in-middle.md: -------------------------------------------------------------------------------- 1 | ## Setup a Raspberry Pi To Intercept PWS Information 2 | 3 | The following steps are based on a Raspberry Pi Zero W with an Ethernet/USB adapter to provide two network interfaces. The installation instructions below assume the PWS Internet Bridge has been connected into the Pi's ethernet port and that the Pi's WiFi interface is being used to connect with the Home Network. 4 | 5 | **Step 1: Install Software and Basic Setup** 6 | 7 | Install the latest version of Raspbian onto the Pi and configure the wifi network as per the instructions from the Raspberry Pi Foundation. You can now `ssh` into the Pi via the WiFi network and contiue the setup process. 8 | 9 | We only need to install one additional piece of software called `dnsmasq` which we will need to manage the network on the ethernet side of the Pi. We don't want any of the default configuration as we need to tailor that to our specific needs: 10 | 11 | ``` 12 | pi@raspberry:~ $ sudo apt-get install dnsmasq 13 | pi@raspberry:~ $ sudo rm -rf /etc/dnsmasq.d/* 14 | ``` 15 | 16 | Then, we need to change one of the default Raspberry Pi setting to enable IP forwarding. We will be using this forwarding functionality later in the installation process. The setting can be changed by editing the file `sysctl.conf`: 17 | 18 | ``` 19 | pi@raspberry:~ $ sudo nano /etc/sysctl.conf 20 | ``` 21 | Uncomment the line "`# net.ipv4.ip_forward=1`" to look as follows and save the file: 22 | ``` 23 | net.ipv4.ip_forward=1 24 | ``` 25 | We now have a pretty standard Raspberry Pi installation with the Pi connected to our Home Network via the WiFi interface. 26 | 27 | **Step 2: Configure the PWS Side of the Network** 28 | 29 | We now need to shift our focus across to the ethernet side of the Pi. At the moment, we have the PWS physically connected to the Pi via the ethernet port but have yet to setup the networking layer to communicate with the PWS. 30 | 31 | Wwe need to assign a static address to the Pi's ethernet port (`eth0`). This is the port connected to the PWS Internet Bridge and will act as the "network controller" for the ethernet side of things. Since my home network is configured to use `192.168.1.0-255`, I choose to use `192.168.2.0-255` for the network on the ethernet side. To make these changes, we need to edit the `dhcp.conf` configuration file: 32 | 33 | ``` 34 | pi@raspberry:~ $ sudo nano /etc/dhcpcd.conf 35 | ``` 36 | 37 | Adding the following lines to the end of the file: 38 | 39 | ``` 40 | interface eth0 41 | static ip_address=192.168.2.1/24 42 | static routers=192.168.2.0 43 | ``` 44 | 45 | Now we need to configure `dnsmasq` to allocate an IP address to our PWS Internet Gateway so that it can connect and communicate with the Pi. In order for the PWS to get the same static address each time it restarts, we need to tell `dnsmasq` the MAC address of the PWS and the Hostname and IP Address we want it to have. For example, my Ambient Weather PWS has a MAC Address of 00:0E:C6:XX:XX:XX and I want it to be known as "PWS" at 192.168.2.10. 46 | 47 | We need to create a new file to configure our specific requirements: 48 | ``` 49 | pi@raspberry:~ $ sudo nano /etc/dnsmasq.d/eth0-dnsmasq.conf 50 | ``` 51 | Add the following lines of configuration to the file (swapping out , and with our required values): 52 | ``` 53 | interface=eth0 54 | bind-interfaces 55 | server=8.8.8.8 56 | domain-needed 57 | bogus-priv 58 | dhcp-range=192.168.2.2,192.168.2.100,12h 59 | dhcp-host=,, 60 | ``` 61 | **Step 3: Configure the Intercept (Port Forwarding)** 62 | 63 | Now that we have both sides of the network configured, we can setup the Pi to intercept weather observations sent by the PWS Internet Bridge to Weather Underground. We do this by identifying all packets arriving at the Pi from the PWS Internet Gateway and heading towards Port 80 (the WU cloud port). 64 | 65 | These packets can be redirected to the IP and Port of our local Weather Service using the `iptable` command. We will need to setup the configuration and then save it to a file `iptables.ipv4.nat` so that we can restore the configuration easily after a reboot. When executing the commands below, make sure to substitute with the PWS address selected earlier and to use the IP and Port for your local Weather Service in place of ``: 66 | ``` 67 | pi@raspberry:~ $ sudo iptables -t nat -A PREROUTING -s -p tcp --dport 80 -j DNAT --to-destination 68 | pi@raspberry:~ $ sudo iptables -t nat -A POSTROUTING -j MASQUERADE 69 | pi@raspberry:~ $ sudo sh -c "iptables-save >/etc/iptables.ipv4.nat" 70 | ``` 71 | In order to ensure these forwarding rules are always operating, we need to create a small batch file called `/etc/network/if-up.d/eth0-iptables` that is run every time the ethernet inerface is started: 72 | ``` 73 | pi@raspberry:~ $ sudo nano /etc/network/if-up.d/eth0-iptables 74 | ``` 75 | Add the following lines: 76 | ``` 77 | #!/bin/sh 78 | sudo iptables-restore < /etc/iptables.ipv4.nat 79 | ``` 80 | Lastly, ensure that the file is executable: 81 | ``` 82 | pi@raspberry:~ $ sudo chmod +x /etc/network/if-up.d/eth0-iptables 83 | ``` 84 | We have now configured the various port forwarding rules and ensured they will survive a reboot and/or a restart of the ethernet interface. 85 | 86 | **Step 4: Start the Redirection of Weather Observations** 87 | 88 | All of the configuration has been completed and the Raspberry Pi can be rebooted to activate the redirection of PWS observations to the local Weather Service: 89 | 90 | ``` 91 | pi@raspberry:~ $ sudo reboot 92 | ``` 93 | -------------------------------------------------------------------------------- /src/routes/weatherProviders/OWM.ts: -------------------------------------------------------------------------------- 1 | import { GeoCoordinates, PWS, WeatherData, WateringData } from "../../types"; 2 | import { httpJSONRequest, keyToUse, localTime } from "../weather"; 3 | import { WeatherProvider } from "./WeatherProvider"; 4 | import { approximateSolarRadiation, CloudCoverInfo } from "../adjustmentMethods/EToAdjustmentMethod"; 5 | import geoTZ from "geo-tz"; 6 | import { CodedError, ErrorCode } from "../../errors"; 7 | import { addHours, format, getUnixTime, startOfDay, subDays } from "date-fns"; 8 | 9 | export default class OWMWeatherProvider extends WeatherProvider { 10 | 11 | private API_KEY: string; 12 | 13 | public constructor() { 14 | super(); 15 | this.API_KEY = process.env.OWM_API_KEY; 16 | } 17 | 18 | protected async getWateringDataInternal(coordinates: GeoCoordinates, pws: PWS | undefined): Promise { 19 | 20 | const localKey = keyToUse(this.API_KEY, pws); 21 | 22 | //Get previous date by using UTC 23 | const yesterday = subDays(startOfDay(localTime(coordinates)), 1); 24 | 25 | const yesterdayUrl = `https://api.openweathermap.org/data/3.0/onecall/day_summary?units=imperial&appid=${ localKey }&lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }&date=${format(yesterday, "yyyy-MM-dd")}&tz=${format(yesterday, "xxx")}`; 26 | 27 | // Perform the HTTP request to retrieve the weather data 28 | let historicData; 29 | try { 30 | historicData = await httpJSONRequest(yesterdayUrl); 31 | } catch ( err ) { 32 | console.error( "Error retrieving weather information from OWM:", err ); 33 | throw new CodedError( ErrorCode.WeatherApiError ); 34 | } 35 | 36 | // Indicate watering data could not be retrieved if the forecast data is incomplete. 37 | if ( !historicData ) { 38 | throw new CodedError( ErrorCode.MissingWeatherField ); 39 | } 40 | 41 | let clouds = (new Array(24)).fill(historicData.cloud_cover.afternoon); 42 | 43 | const cloudCoverInfo: CloudCoverInfo[] = clouds.map( ( sample, i ): CloudCoverInfo => { 44 | const start = addHours(yesterday, i); 45 | if( sample === undefined ) { 46 | return { 47 | startTime: start, 48 | endTime: start, 49 | cloudCover: 0 50 | } 51 | } 52 | return { 53 | startTime: start, 54 | endTime: addHours(start, 1), 55 | cloudCover: sample / 100 56 | } 57 | }); 58 | 59 | let temp = historicData.temperature; 60 | 61 | let totalTemp = temp.min + temp.max + temp.afternoon + temp.night + temp.evening + temp.morning; 62 | 63 | return [{ 64 | weatherProvider: "OWM", 65 | temp: totalTemp / 6, 66 | humidity: historicData.humidity.afternoon, 67 | // OWM always returns precip in mm, so it must be converted. 68 | precip: historicData.precipitation.total / 25.4, 69 | periodStartTime: getUnixTime(yesterday), 70 | minTemp: historicData.temperature.min, 71 | maxTemp: historicData.temperature.max, 72 | minHumidity: historicData.humidity.afternoon, 73 | maxHumidity: historicData.humidity.afternoon, 74 | solarRadiation: approximateSolarRadiation( cloudCoverInfo, coordinates ), 75 | // Assume wind speed measurements are taken at 2 meters. 76 | // Use max of yesterday divided by 2 as ballpark estimate since the API only provides max and not min 77 | windSpeed: historicData.wind.max.speed / 2, 78 | }]; 79 | } 80 | 81 | protected async getWeatherDataInternal(coordinates: GeoCoordinates, pws: PWS | undefined): Promise { 82 | 83 | const localKey = keyToUse(this.API_KEY, pws); 84 | 85 | // The OWM free API options changed so need to use the new API method 86 | const weatherDataUrl = `https://api.openweathermap.org/data/3.0/onecall?units=imperial&lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }&exclude=minutely,hourly,alerts&appid=${ localKey }` 87 | 88 | let weatherData; 89 | try { 90 | weatherData = await httpJSONRequest(weatherDataUrl); 91 | 92 | } catch ( err ) { 93 | console.error( "Error retrieving weather information from OWM:", err ); 94 | throw "An error occurred while retrieving weather information from OWM." 95 | } 96 | 97 | // Indicate weather data could not be retrieved if the forecast data is incomplete. 98 | if (!weatherData || !weatherData.current || !weatherData.daily) { 99 | throw "Necessary field(s) were missing from weather information returned by OWM."; 100 | } 101 | 102 | const weather: WeatherData = { 103 | weatherProvider: "OWM", 104 | temp: weatherData.current.temp, 105 | humidity: weatherData.current.humidity, 106 | wind: weatherData.current.wind_speed, 107 | raining: (weatherData.current.rain?.["1h"] || 0) > 0, 108 | description: weatherData.current.weather[0].description, 109 | icon: weatherData.current.weather[0].icon, 110 | 111 | region: "", 112 | city: "", 113 | minTemp: weatherData.daily[0].temp.min, 114 | maxTemp: weatherData.daily[0].temp.max, 115 | precip: (weatherData.daily[0].rain ? weatherData.daily[0].rain : 0) / 25.4, 116 | forecast: [] 117 | }; 118 | 119 | for (let index = 0; index < weatherData.daily.length; index++) { 120 | weather.forecast.push({ 121 | temp_min: weatherData.daily[index].temp.min, 122 | temp_max: weatherData.daily[index].temp.max, 123 | precip: (weatherData.daily[index].rain ? weatherData.daily[index].rain : 0) / 25.4, 124 | date: weatherData.daily[index].dt, 125 | icon: weatherData.daily[index].weather[0].icon, 126 | description: weatherData.daily[index].weather[0].description 127 | }); 128 | } 129 | 130 | return weather; 131 | } 132 | 133 | pad(number: number){ 134 | return (number<10 ? "0" + number.toString() : number.toString()); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/routes/weatherProviders/local.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import fs from "fs"; 3 | 4 | import { GeoCoordinates, WeatherData, WateringData, PWS } from "../../types"; 5 | import { WeatherProvider } from "./WeatherProvider"; 6 | import { CodedError, ErrorCode } from "../../errors"; 7 | import { getParameter } from "../weather"; 8 | 9 | var queue: Array = [], 10 | lastRainEpoch = 0, 11 | lastRainCount: number; 12 | 13 | function getMeasurement(req: express.Request, key: string): number { 14 | let value: number; 15 | 16 | return ( key in req.query ) && !isNaN( value = parseFloat( getParameter(req.query[key]) ) ) && ( value !== -9999.0 ) ? value : undefined; 17 | } 18 | 19 | export const captureWUStream = async function( req: express.Request, res: express.Response ) { 20 | let rainCount = getMeasurement(req, "dailyrainin"); 21 | 22 | const obs: Observation = { 23 | timestamp: req.query.dateutc === "now" ? Math.floor(Date.now()/1000) : Math.floor(new Date(String(req.query.dateutc) + "Z").getTime()/1000), 24 | temp: getMeasurement(req, "tempf"), 25 | humidity: getMeasurement(req, "humidity"), 26 | windSpeed: getMeasurement(req, "windspeedmph"), 27 | solarRadiation: getMeasurement(req, "solarradiation") * 24 / 1000, // Convert to kWh/m^2 per day 28 | precip: rainCount < lastRainCount ? rainCount : rainCount - lastRainCount, 29 | }; 30 | 31 | lastRainEpoch = getMeasurement(req, "rainin") > 0 ? obs.timestamp : lastRainEpoch; 32 | lastRainCount = isNaN(rainCount) ? lastRainCount : rainCount; 33 | 34 | queue.unshift(obs); 35 | 36 | res.send( "success\n" ); 37 | }; 38 | 39 | export default class LocalWeatherProvider extends WeatherProvider { 40 | 41 | protected async getWeatherDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WeatherData > { 42 | queue = queue.filter( obs => Math.floor(Date.now()/1000) - obs.timestamp < 24*60*60 ); 43 | 44 | if ( queue.length == 0 ) { 45 | console.error( "There is insufficient data to support Weather response from local PWS." ); 46 | throw "There is insufficient data to support Weather response from local PWS."; 47 | } 48 | 49 | const weather: WeatherData = { 50 | weatherProvider: "local", 51 | temp: Math.floor( queue[ 0 ].temp ) || undefined, 52 | minTemp: undefined, 53 | maxTemp: undefined, 54 | humidity: Math.floor( queue[ 0 ].humidity ) || undefined , 55 | wind: Math.floor( queue[ 0 ].windSpeed * 10 ) / 10 || undefined, 56 | raining: false, 57 | precip: Math.floor( queue.reduce( ( sum, obs ) => sum + ( obs.precip || 0 ), 0) * 100 ) / 100, 58 | description: "", 59 | icon: "01d", 60 | region: undefined, 61 | city: undefined, 62 | forecast: [] 63 | }; 64 | 65 | if (weather.precip > 0){ 66 | weather.raining = true; 67 | } 68 | 69 | return weather; 70 | } 71 | 72 | protected async getWateringDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WateringData[] > { 73 | 74 | queue = queue.filter( obs => Math.floor(Date.now()/1000) - obs.timestamp < 24*60*60 ); 75 | 76 | if ( queue.length == 0 || queue[ 0 ].timestamp - queue[ queue.length - 1 ].timestamp < 23*60*60 ) { 77 | console.error( "There is insufficient data to support watering calculation from local PWS." ); 78 | throw new CodedError( ErrorCode.InsufficientWeatherData ); 79 | } 80 | 81 | let cTemp = 0, cHumidity = 0, cPrecip = 0, cSolar = 0, cWind = 0; 82 | const result: WateringData = { 83 | weatherProvider: "local", 84 | temp: queue.reduce( ( sum, obs ) => !isNaN( obs.temp ) && ++cTemp ? sum + obs.temp : sum, 0) / cTemp, 85 | humidity: queue.reduce( ( sum, obs ) => !isNaN( obs.humidity ) && ++cHumidity ? sum + obs.humidity : sum, 0) / cHumidity, 86 | precip: queue.reduce( ( sum, obs ) => !isNaN( obs.precip ) && ++cPrecip ? sum + obs.precip : sum, 0), 87 | periodStartTime: Math.floor( queue[ queue.length - 1 ].timestamp ), 88 | minTemp: queue.reduce( (min, obs) => ( min > obs.temp ) ? obs.temp : min, Infinity ), 89 | maxTemp: queue.reduce( (max, obs) => ( max < obs.temp ) ? obs.temp : max, -Infinity ), 90 | minHumidity: queue.reduce( (min, obs) => ( min > obs.humidity ) ? obs.humidity : min, Infinity ), 91 | maxHumidity: queue.reduce( (max, obs) => ( max < obs.humidity ) ? obs.humidity : max, -Infinity ), 92 | solarRadiation: queue.reduce( (sum, obs) => !isNaN( obs.solarRadiation ) && ++cSolar ? sum + obs.solarRadiation : sum, 0) / cSolar, 93 | windSpeed: queue.reduce( (sum, obs) => !isNaN( obs.windSpeed ) && ++cWind ? sum + obs.windSpeed : sum, 0) / cWind 94 | }; 95 | 96 | if ( !( cTemp && cHumidity && cPrecip ) || 97 | [ result.minTemp, result.minHumidity, -result.maxTemp, -result.maxHumidity ].includes( Infinity ) || 98 | !( cSolar && cWind && cPrecip )) { 99 | console.error( "There is insufficient data to support watering calculation from local PWS." ); 100 | throw new CodedError( ErrorCode.InsufficientWeatherData ); 101 | } 102 | 103 | return [result]; 104 | }; 105 | 106 | } 107 | 108 | function saveQueue() { 109 | queue = queue.filter( obs => Math.floor(Date.now()/1000) - obs.timestamp < 24*60*60 ); 110 | try { 111 | fs.writeFileSync( "observations.json" , JSON.stringify( queue ), "utf8" ); 112 | } catch ( err ) { 113 | console.error( "Error saving historical observations to local storage.", err ); 114 | } 115 | } 116 | 117 | if ( process.env.WEATHER_PROVIDER === "local" && process.env.LOCAL_PERSISTENCE ) { 118 | if ( fs.existsSync( "observations.json" ) ) { 119 | try { 120 | queue = JSON.parse( fs.readFileSync( "observations.json", "utf8" ) ); 121 | queue = queue.filter( obs => Math.floor(Date.now()/1000) - obs.timestamp < 24*60*60 ); 122 | } catch ( err ) { 123 | console.error( "Error reading historical observations from local storage.", err ); 124 | queue = []; 125 | } 126 | } 127 | setInterval( saveQueue, 1000 * 60 * 30 ); 128 | } 129 | 130 | interface Observation { 131 | timestamp: number; 132 | temp: number; 133 | humidity: number; 134 | windSpeed: number; 135 | solarRadiation: number; 136 | precip: number; 137 | } 138 | -------------------------------------------------------------------------------- /docs/wifi-hotspot.md: -------------------------------------------------------------------------------- 1 | ## Setup a Raspberry Pi To Intercept PWS Information (via Access Point) 2 | 3 | The following steps are based on a Raspberry Pi Zero W with an Ethernet/USB adapter to provide two network interfaces. The installation instructions below assume that the Pi's ethernet interface is connect to the Home Network and the PWS will be connected to the Pi's wifi port. 4 | 5 | **Step 1: Install Software and Basic Setup** 6 | 7 | Install the latest version of Raspbian onto the Pi as per the instructions from the Raspberry Pi Foundation. Do not enable the WiFi interface at this stage instead `ssh` into the Pi via the ethernet network and contiue the setup process. 8 | 9 | We need to install two software packages to allow our Raspberry Pi to connect to our PWS and send the weather information across to our home network. The first, `hostapd`, will provide an access point to connect the PWS, and the second, `bridge-utils`, will route the information from the wifi-side of the rapsberry pi to the ethernet-side: 10 | ``` 11 | pi@raspberry:~ $ sudo apt-get install hostapd bridge-utils 12 | ``` 13 | We need to change one of the default Raspberry Pi setting to enable IP forwarding. We will be using this forwarding functionality later in the installation process. The setting can be changed by editing the file `sysctl.conf`: 14 | 15 | ``` 16 | pi@raspberry:~ $ sudo nano /etc/sysctl.conf 17 | ``` 18 | Uncomment the line "`# net.ipv4.ip_forward=1`" to look as follows and save the file: 19 | ``` 20 | net.ipv4.ip_forward=1 21 | ``` 22 | We now have a pretty standard Raspberry Pi installation with the Pi connected to our Home Network via the ethernet interface. 23 | 24 | **Step 2: Configure a "Bridge" connecting the Pi's WiFi and Ethernet interfaces** 25 | 26 | In order to create a "bridge" between the wifi-side and the ethernet-side of the Pi we need to make a few changes in a file called `dhcp.conf` as follows: 27 | ``` 28 | pi@raspberry:~ $ sudo nano /etc/dhcpcd.conf 29 | ``` 30 | Add two line to the end of the file but above any other added interface lines and save the file: 31 | ``` 32 | denyinterfaces wlan0 33 | denyinterfaces eth0 34 | ``` 35 | 36 | Now the interfaces file needs to be edited to make the two interfaces act as a bridge: 37 | ``` 38 | pi@raspberry:~ $ sudo nano /etc/network/interfaces 39 | ``` 40 | Add the following lines to the end of the file: 41 | ``` 42 | # Bridge setup 43 | auto br0 44 | iface br0 inet manual 45 | bridge_ports eth0 wlan0 46 | ``` 47 | **Step 3: Setup the WiFi Access Point** 48 | 49 | Now we need to provide a mechanism to allow the PWS to connect to the Raspberry Pi's WiFi. We do this using the `hostapd` package to create a dedicated Access Point for the PWS. 50 | 51 | You need to edit the hostapd configuration file, located at `/etc/hostapd/hostapd.conf`. This is an empty file so we just need to open it up in an editor add some line from below: 52 | ``` 53 | pi@raspberry:~ $ sudo nano /etc/hostapd/hostapd.conf 54 | ``` 55 | Add the information below to the configuration file. This configuration assumes we are using channel `7`, with a network name of `PWSAccessPoint`, and a password `PWSSecretPassword`. Note that the name and password should not have quotes around them. The passphrase should be between 8 and 64 characters in length. 56 | 57 | To use the 5 GHz band, you can change the operations mode from hw_mode=g to hw_mode=a. Possible values for hw_mode are: 58 | 59 | - a = IEEE 802.11a (5 GHz) 60 | - b = IEEE 802.11b (2.4 GHz) 61 | - g = IEEE 802.11g (2.4 GHz) 62 | - ad = IEEE 802.11ad (60 GHz) 63 | ``` 64 | interface=wlan0 65 | bridge=br0 66 | ssid=PWSAccessPoint 67 | hw_mode=g 68 | channel=7 69 | wmm_enabled=0 70 | macaddr_acl=0 71 | auth_algs=1 72 | ignore_broadcast_ssid=0 73 | wpa=2 74 | wpa_passphrase=PWSSecretPassword 75 | wpa_key_mgmt=WPA-PSK 76 | wpa_pairwise=TKIP 77 | rsn_pairwise=CCMP 78 | ``` 79 | We now need to tell the system where to find this configuration file: 80 | ``` 81 | pi@raspberry:~ $ sudo nano /etc/default/hostapd 82 | ``` 83 | Add the line below to the end of the file: 84 | ``` 85 | DAEMON_CONF="/etc/hostapd/hostapd.conf" 86 | ``` 87 | We can now activate the Access Point with the following commands: 88 | ``` 89 | pi@raspberry:~ $ sudo systemctl unmask hostapd 90 | pi@raspberry:~ $ sudo systemctl enable hostapd 91 | ``` 92 | Reboot the Raspberry Pi for all of these changes to take effect: 93 | ``` 94 | pi@raspberry:~ $ sudo reboot 95 | ``` 96 | You should now be able to go to your PWS configuration screen and connect the PWS to this new Access Point. At this point your PWS should be sending weather data to the Weather Underground cloud and you should confirm that is happening to ensure we haven't made any mistakes thus far. 97 | 98 | **Step 4: Configure the Intercept (Port Forwarding)** 99 | 100 | Now that we have the PWS connected to the Raspberry Pi's WiFi access point and sending information to Weather Underground, we can set-up the intercept to redirect that information to our local Weather Service. We do this by identifying all packets arriving at the Pi from the PWS and heading towards Port 80 (the WU cloud port). 101 | 102 | These packets can be redirected to the IP and Port of our local Weather Service using the `iptable` command. We will need to setup the configuration and then save it to a file `iptables.ipv4.nat` so that we can restore the configuration easily after a reboot. When executing the commands below, make sure to substitute with your PWS address and to use the IP and Port for your local Weather Service in place of ``: 103 | ``` 104 | pi@raspberry:~ $ sudo iptables -t nat -A PREROUTING -m physdev --physdev-in wlan0 -s -p tcp --dport 80 -j DNAT --to-destination 105 | pi@raspberry:~ $ sudo sh -c "iptables-save > /etc/iptables.ipv4.nat" 106 | ``` 107 | In order to ensure these forwarding rules are always operating, we need to create a small batch file called `/etc/network/if-up.d/eth0-iptables` that is run every time the ethernet inerface is started: 108 | ``` 109 | pi@raspberry:~ $ sudo nano /etc/network/if-up.d/eth0-iptables 110 | ``` 111 | Add the following lines: 112 | ``` 113 | #!/bin/sh 114 | sudo iptables-restore < /etc/iptables.ipv4.nat 115 | ``` 116 | Lastly, ensure that the file is executable: 117 | ``` 118 | pi@raspberry:~ $ sudo chmod +x /etc/network/if-up.d/eth0-iptables 119 | ``` 120 | We have now configured the various port forwarding rules and ensured they will survive a reboot and/or a restart of the ethernet interface. 121 | 122 | **Step 5: Start the Redirection of Weather Observations and Test it is Working** 123 | 124 | All of the configuration has been completed and the Raspberry Pi can be rebooted to activate the redirection of PWS observations to the local Weather Service: 125 | 126 | ``` 127 | pi@raspberry:~ $ sudo reboot 128 | ``` 129 | At this point you should have information flowing from your PWS into the local Weather Service available for use by OS. You can test the service is operating correctly by going back to the instructions on "Installing a Local Weather Service" and following the penultimate Step 7. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

 OpenSprinkler Weather Service [![GitHub version](https://img.shields.io/github/package-json/v/opensprinkler/opensprinkler-weather.svg)](https://github.com/OpenSprinkler/OpenSprinkler-Weather)

2 |  [![Build Status](https://api.travis-ci.org/OpenSprinkler/OpenSprinkler-Weather.svg?branch=master)](https://travis-ci.org/) [![devDependency Status](https://david-dm.org/OpenSprinkler/OpenSprinkler-Weather/status.svg)](https://david-dm.org/OpenSprinkler/OpenSprinkler-Weather#info=dependencies)
3 |  [Official Site][official] | [Support][help] | [Changelog][changelog] 4 |
5 | This script works with the OpenSprinkler Unified Firmware to automatically adjust station run times based on weather data. In addition to calculating the watering level, it also supplies details such as the user’s time zone, sunrise, and sunset times, based on the user's location information. The script is implemented in JavaScript and runs on Node.js. 6 | 7 | --- 8 | 9 | [official]: https://opensprinkler.com 10 | [help]: http://support.opensprinkler.com 11 | [changelog]: https://github.com/OpenSprinkler/OpenSprinkler-Weather/releases 12 | 13 | ## File Detail 14 | 15 | **server.js** is the primary file launching the API daemon. 16 | 17 | **src/routes/** contains all the endpoints for the API service, including weather data providers, adjustment methods, geocoders. The list of currently supported weather data providers, their capabilities, and details on various adjustment methods can be found at our [support website]: https://openthings.freshdesk.com/support/solutions/articles/5000823370-use-weather-adjustments 18 | 19 | --- 20 | 21 | ## Running the Weather Script Locally 22 | 23 | To run the weather script on your own computer, start by downloading the source code (either via `git clone` or a ZIP download). Then install dependencies and compile the TypeScript sources: 24 | 25 | ``` 26 | npm install 27 | npm run build 28 | ``` 29 | 30 | ### 1. Compose `.env` file 31 | Before starting the service, you’ll need a `.env` file with configuration parameters such as the server port, default weather provider, geocoder, and any required API keys. A minimal example looks like this: 32 | 33 | ``` 34 | HOST=0.0.0.0 35 | PORT=3000 36 | GEOCODER=GoogleMaps 37 | GOOGLE_MAPS_API_KEY=your_api_key 38 | ``` 39 | 40 | Note: The `GOOGLE_MAPS_API_KEY` does not need to be valid if you query the service directly with GPS coordinates. The Maps API is only used for geocoding (converting a city name or ZIP code into latitude/longitude). 41 | 42 | To set a default weather provider (e.g. `OpenMeteo`), include: 43 | 44 | ``` 45 | WEATHER_PROVIDER=OpenMeteo 46 | ``` 47 | 48 | If your chosen provider requires an API key (for example, OpenWeatherMap or `OWM`), add: 49 | 50 | ``` 51 | WEATHER_PROVIDER=OWM 52 | OWM_API_KEY=your_owm_api_key 53 | ``` 54 | 55 | Unlike earlier versions, this script also allows you to specify the weather provider and API key dynamically via the `wto` parameter in API queries. This means you don’t have to hardcode a default provider in `.env` unless you prefer to. 56 | 57 | ### 2. Build the baselineETo data: 58 | 59 | ``` 60 | cd baselineEToData 61 | sh prepareData.sh 20 62 | sh baseline.sh 63 | ``` 64 | 65 | This command runs the data preparation script with `20` interpolation passes (the recommended default, explained in the `README` for that folder). When it finishes, it will produce the file `Baseline_ETo_Data.bin`, which is required by the weather service for ETo-based watering adjustments. This file only needs to be built once -- you don't need to generate it again if you already have it. 66 | 67 | ### 3. Start the Service 68 | Once your `.env` file is ready and the baseline ETo data is prepared, start the service with: 69 | 70 | ``` 71 | npm run start 72 | ``` 73 | 74 | The server will launch on the port you configured in `.env`. 75 | 76 | --- 77 | 78 | ## Running the Weather Service with Docker 79 | 80 | You can also run the precompiled weather service in Docker. The GitHub repository automatically publishes an up-to-date image, which you can pull with: 81 | 82 | `ghcr.io/opensprinkler/weather-server:release` 83 | 84 | To launch it as a background service (daemon), run the container and point it to your `.env` file for configuration. The .env setup is the same as described above, but note that the Docker image already includes the `Baseline_ETo_Data.bin`, so you don’t need to generate it yourself. 85 | 86 | If you prefer to build the Docker image locally, be aware that the process is resource-intensive. You will need at least 30 GB of free disk space and sufficient memory to complete the build, since generating the baseline ETo dataset is computationally heavy. 87 | 88 | --- 89 | 90 | ## Using the Weather Service 91 | 92 | When the weather server is running, it starts a web service at `:3000`, where `` is the IP/DNS of your computer and `3000` is the default port. The OpenSprinkler firmware and UI/app communicate with it through these endpoints: 93 | 94 | - `:3000`: Returns the weather service version. 95 | 96 | - `:3000/0?loc=[long],[lat]`: Used for **Manual** adjustment. 97 | - `[long],[lat]` are GPS coordinates (e.g. `42,-75`). 98 | - Returns time zone, sunrise/sunset times, and an error code if any. 99 | - Time zone is encoded as `(GMT shift × 4) + 48`. For example, `GMT-4` is encoded as `32`. 100 | - Sunrise/sunset are given in minutes since midnight. 101 | - If a valid geocoder (e.g. Google Maps with API key) is set, location may also be provided as ZIP code, city, etc. 102 | 103 | - `:3000/1?loc=[long],[lat]&wto="h":100,"t":100,"r":100,"bh":30,"bt":70,"br":0`: Used for **Zimmerman** adjustment method. 104 | - `wto` specifies the optional adjustment parameters, such as weights and baselines of humidity, temperature, and rain. 105 | - Returns all parameters from `/0`, plus: 106 | - `scale`: watering level calculated by the Zimmerman algorithm from yesterday's data. 107 | - `rawData`: the raw weather data used for Zimmerman calculation 108 | - `scales`: multi-day averages based on available historic data. The length of this array depends on the selected weather data provider's capability. 109 | 110 | - `:3000/2?loc=[long],[lat]&wto="d":28`: Used for **Auto Rain Delay**, where `d` is the number of hours to delay if rain is currently reported. 111 | 112 | - `:3000/3?loc=[long],[lat]&wto="baseETo":0.34,"elevation":600`: Used for **ETo** adjustment. `baseETo` is the baseline ETo value in inches/day; `elevation` is the elevation in feet. Returns `scale`, `rawData` and `scales` array similar to Zimmerman. 113 | 114 | - **Weather Constraints** can be added via `wto` to any adjustment method above. For example: 115 | - `"minTemp":78` triggers a return parameter of `restrict=1` if the current temperature is below `78°F`. 116 | - `"rainAmt":1.5,"rainDays":4` triggers `restrict=1` if the forecast rain is more than 1.5 (inches) in the next 4 days. 117 | - `"cali":` enables California restriction (stop watering if ≥0.1″ rain in past 48h). 118 | 119 | - **Weather Data Provider** can be specified with any adjustment method by adding `"provider":"X","key":Y` to `wto`. For example: 120 | - `"provider":"OpenMeteo"` for OpenMeteo (no key required) 121 | - `"provider":"AW","key":"xxxx"` for AccuWeather with the corresponding key. 122 | 123 | - `:3000/weatherData?loc=[long],[lat]`: Return forecast data. A provider can also be set via `wto`. 124 | -------------------------------------------------------------------------------- /src/routes/baselineETo.ts: -------------------------------------------------------------------------------- 1 | /* This script requires the file Baseline_ETo_Data.bin file to be created in the baselineEToData directory. More 2 | * information about this is available in /baselineEToData/README.md. 3 | */ 4 | import express from "express"; 5 | import fs from "fs"; 6 | import path from "path"; 7 | import { GeoCoordinates } from "../types"; 8 | import { getParameter, resolveCoordinates } from "./weather"; 9 | 10 | const DATA_FILE = process.env.BASELINE_ETO_FILE || path.join(__dirname, "..", "baselineEToData", "Baseline_ETo_Data.bin"); 11 | let FILE_META: FileMeta; 12 | 13 | readFileHeader().then( ( fileMeta ) => { 14 | FILE_META = fileMeta; 15 | console.log( "Loaded baseline ETo data." ); 16 | } ).catch( ( err ) => { 17 | console.error( "An error occurred while reading the annual ETo data file header. Baseline ETo endpoint will be unavailable.", err ); 18 | } ); 19 | 20 | export const getBaselineETo = async function( req: express.Request, res: express.Response ) { 21 | const location: string = getParameter( req.query.loc ); 22 | 23 | // Error if the file meta was not read (either the file is still being read or an error occurred and it could not be read). 24 | if ( !FILE_META ) { 25 | res.status( 503 ).send( "Baseline ETo calculation is currently unavailable." ); 26 | return; 27 | } 28 | 29 | // Attempt to resolve provided location to GPS coordinates. 30 | let coordinates: GeoCoordinates; 31 | try { 32 | coordinates = await resolveCoordinates( location ); 33 | } catch (err) { 34 | res.status( 404 ).send( `Error: Unable to resolve coordinates for location (${ err })` ); 35 | return; 36 | } 37 | 38 | let eto: number; 39 | try { 40 | eto = await calculateAverageDailyETo( coordinates ); 41 | } catch ( err ) { 42 | /* Use a 500 error code if a more appropriate error code is not specified, and prefer the error message over the 43 | full error object if a message is defined. */ 44 | res.status( err.code || 500 ).send( err.message || err ); 45 | return; 46 | } 47 | 48 | res.status( 200 ).json( { 49 | eto: Math.round( eto * 1000 ) / 1000 50 | } ); 51 | }; 52 | 53 | /** 54 | * Retrieves the average daily potential ETo for the specified location. 55 | * @param coordinates The location to retrieve the ETo for. 56 | * @return A Promise that will be resolved with the average potential ETo (in inches per day), or rejected with an error 57 | * (which may include a message and the appropriate HTTP status code to send the user) if the ETo cannot be retrieved. 58 | */ 59 | async function calculateAverageDailyETo( coordinates: GeoCoordinates ): Promise< number > { 60 | // Convert geographic coordinates into image coordinates. 61 | const x = Math.floor( FILE_META.origin.x + FILE_META.width * coordinates[ 1 ] / 360 ); 62 | // Account for the 30+10 cropped degrees. 63 | const y = Math.floor( FILE_META.origin.y - FILE_META.height * coordinates[ 0 ] / ( 180 - 30 - 10 ) ); 64 | 65 | // The offset (from the start of the data block) of the relevant pixel. 66 | const offset = y * FILE_META.width + x; 67 | 68 | /* Check if the specified coordinates were invalid or correspond to a part of the map that was cropped. */ 69 | if ( offset < 0 || offset > FILE_META.width * FILE_META.height ) { 70 | throw { message: "Specified location is out of bounds.", code: 404 }; 71 | } 72 | 73 | let byte: number; 74 | try { 75 | // Skip the 32 byte header. 76 | byte = await getByteAtOffset( offset + 32 ); 77 | } catch ( err ) { 78 | console.error( `An error occurred while reading the baseline ETo data file for coordinates ${ coordinates }:`, err ); 79 | throw { message: "An unexpected error occurred while retrieving the baseline ETo for this location.", code: 500 } 80 | } 81 | 82 | // The maximum value indicates that no data is available for this point. 83 | if ( ( byte === ( 1 << FILE_META.bitDepth ) - 1 ) ) { 84 | throw { message: "ETo data is not available for this location.", code: 404 }; 85 | } 86 | 87 | return ( byte * FILE_META.scalingFactor + FILE_META.minimumETo ) / 365; 88 | } 89 | 90 | /** 91 | * Returns the byte at the specified offset in the baseline ETo data file. 92 | * @param offset The offset from the start of the file (the start of the header, not the start of the data block). 93 | * @return A Promise that will be resolved with the unsigned representation of the byte at the specified offset, or 94 | * rejected with an Error if an error occurs. 95 | */ 96 | function getByteAtOffset( offset: number ): Promise< number > { 97 | return new Promise( ( resolve, reject ) => { 98 | const stream = fs.createReadStream( DATA_FILE, { start: offset, end: offset } ); 99 | 100 | stream.on( "error", ( err ) => { 101 | reject( err ); 102 | } ); 103 | 104 | // There's no need to wait for the "end" event since the "data" event will contain the single byte being read. 105 | stream.on( "data", ( data ) => { 106 | resolve( data[ 0 ] ); 107 | } ); 108 | } ); 109 | } 110 | 111 | /** 112 | * Parses information from the baseline ETo data file from the file header. The header format is documented in the README. 113 | * @return A Promise that will be resolved with the parsed header information, or rejected with an error if the header 114 | * is invalid or cannot be read. 115 | */ 116 | function readFileHeader(): Promise< FileMeta > { 117 | return new Promise( ( resolve, reject) => { 118 | const stream = fs.createReadStream( DATA_FILE, { start: 0, end: 32 } ); 119 | const headerArray: number[] = []; 120 | 121 | stream.on( "error", ( err ) => { 122 | reject( err ); 123 | } ); 124 | 125 | stream.on( "data", ( data: number[] ) => { 126 | headerArray.push( ...data ); 127 | } ); 128 | 129 | stream.on( "end", () => { 130 | const buffer = Buffer.from( headerArray ); 131 | const version = buffer.readUInt8( 0 ); 132 | if ( version !== 1 ) { 133 | reject( `Unsupported data file version ${ version }. The maximum supported version is 1.` ); 134 | return; 135 | } 136 | 137 | const width = buffer.readUInt32BE( 1 ); 138 | const height = buffer.readUInt32BE( 5 ); 139 | const fileMeta: FileMeta = { 140 | version: version, 141 | width: width, 142 | height: height, 143 | bitDepth: buffer.readUInt8( 9 ), 144 | minimumETo: buffer.readFloatBE( 10 ), 145 | scalingFactor: buffer.readFloatBE( 14 ), 146 | origin: { 147 | x: Math.floor( width / 2 ), 148 | // Account for the 30+10 cropped degrees. 149 | y: Math.floor( height / ( 180 - 10 - 30) * ( 90 - 10 ) ) 150 | } 151 | }; 152 | 153 | if ( fileMeta.bitDepth === 8 ) { 154 | resolve( fileMeta ); 155 | } else { 156 | reject( "Bit depths other than 8 are not currently supported." ); 157 | } 158 | } ); 159 | } ); 160 | } 161 | 162 | /** Information about the data file parsed from the file header. */ 163 | interface FileMeta { 164 | version: number; 165 | /** The width of the image (in pixels). */ 166 | width: number; 167 | /** The height of the image (in pixels). */ 168 | height: number; 169 | /** The number of bits used for each pixel. */ 170 | bitDepth: number; 171 | /** The ETo that a pixel value of 0 represents (in inches/year). */ 172 | minimumETo: number; 173 | /** The ratio of an increase in pixel value to an increase in ETo (in inches/year). */ 174 | scalingFactor: number; 175 | /** 176 | * The pixel coordinates of the geographic coordinates origin. These coordinates are off-center because the original 177 | * image excludes the northernmost 10 degrees and the southernmost 30 degrees. 178 | */ 179 | origin: { 180 | x: number; 181 | y: number; 182 | }; 183 | } 184 | -------------------------------------------------------------------------------- /src/routes/weatherProviders/WUnderground.ts: -------------------------------------------------------------------------------- 1 | import { GeoCoordinates, PWS, WeatherData, WateringData } from "../../types"; 2 | import { WeatherProvider } from "./WeatherProvider"; 3 | import { httpJSONRequest } from "../weather"; 4 | import { CodedError, ErrorCode } from "../../errors"; 5 | 6 | export default class WUndergroundWeatherProvider extends WeatherProvider { 7 | 8 | protected async getWateringDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WateringData[] > { 9 | if ( !pws ) { 10 | throw new CodedError( ErrorCode.NoPwsProvided ); 11 | } 12 | 13 | const historicUrl = `https://api.weather.com/v2/pws/observations/hourly/7day?stationId=${ pws.id }&format=json&units=e&numericPrecision=decimal&apiKey=${ pws.apiKey }`; 14 | 15 | let historicData; 16 | try { 17 | historicData = await httpJSONRequest( historicUrl ); 18 | } catch ( err ) { 19 | console.error( "Error retrieving weather information from WUnderground:", err ); 20 | throw new CodedError( ErrorCode.WeatherApiError ); 21 | } 22 | 23 | if ( !historicData || !historicData.observations ) { 24 | throw "Necessary field(s) were missing from weather information returned by Wunderground."; 25 | } 26 | 27 | const hours = historicData.observations; 28 | 29 | // Cut hours into 24 hour sections up to the end of day yesterday 30 | hours.length -= (hours.length % 24); // remove the ending remainder since data starts from midnight 7 days ago 31 | 32 | const daysInHours = []; 33 | for (let i = 0; i < hours.length; i+=24){ 34 | daysInHours.push(hours.slice(i, i+24)); 35 | } 36 | 37 | // Fail if not enough data is available. 38 | if ( daysInHours.length < 1 || daysInHours[0].length !== 24 ) { 39 | throw new CodedError( ErrorCode.InsufficientWeatherData ); 40 | } 41 | 42 | const data: WateringData[] = []; 43 | for ( let i = 0; i < daysInHours.length; i++ ){ 44 | let temp: number = 0, humidity: number = 0, precip: number = 0, 45 | minHumidity: number = undefined, maxHumidity: number = undefined, 46 | minTemp: number = undefined, maxTemp: number = undefined, 47 | wind: number = 0, solar: number = 0; 48 | 49 | for ( const hour of daysInHours[i] ) { 50 | 51 | temp += hour.imperial.tempAvg; 52 | humidity += hour.humidityAvg; 53 | 54 | // Each hour is accumulation to present, not per hour precipitation. Using greatest value means last hour of each day is used. 55 | precip = precip > hour.imperial.precipTotal ? precip : hour.imperial.precipTotal; 56 | 57 | minTemp = minTemp < hour.imperial.tempLow ? minTemp : hour.imperial.tempLow; 58 | maxTemp = maxTemp > hour.imperial.tempHigh ? maxTemp : hour.imperial.tempHigh; 59 | 60 | if (hour.imperial.windspeedAvg != null && hour.imperial.windspeedAvg > wind) 61 | wind = hour.imperial.windspeedAvg; 62 | 63 | if (hour.solarRadiationHigh != null) 64 | solar += hour.solarRadiationHigh; 65 | 66 | minHumidity = minHumidity < hour.humidityLow ? minHumidity : hour.humidityLow; 67 | maxHumidity = maxHumidity > hour.humidityHigh ? maxHumidity : hour.humidityHigh; 68 | } 69 | 70 | data.push( { 71 | weatherProvider: "WU", 72 | temp: temp / 24, 73 | humidity: humidity / 24, 74 | precip: precip, 75 | periodStartTime: daysInHours[i][0].epoch, 76 | minTemp: minTemp, 77 | maxTemp: maxTemp, 78 | minHumidity: minHumidity, 79 | maxHumidity: maxHumidity, 80 | solarRadiation: solar / 1000, // Returned in Watts from API 81 | // Assume wind speed measurements are taken at 2 meters. 82 | windSpeed: wind 83 | } ); 84 | } 85 | 86 | return data.reverse(); 87 | 88 | } 89 | 90 | protected async getWeatherDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WeatherData > { 91 | if ( !pws ) { 92 | throw new CodedError( ErrorCode.NoPwsProvided ); 93 | } 94 | 95 | const forecastURL = `https://api.weather.com/v3/wx/forecast/daily/5day?geocode=${ coordinates[ 0 ] },${ coordinates[ 1 ] }&format=json&language=en-US&units=e&apiKey=${ pws.apiKey }`; 96 | 97 | let forecast; 98 | try { 99 | forecast = await httpJSONRequest( forecastURL ); 100 | } catch ( err ) { 101 | console.error( "Error retrieving weather information from WUnderground:", err ); 102 | throw new CodedError( ErrorCode.WeatherApiError ); 103 | } 104 | 105 | const currentURL = `https://api.weather.com/v2/pws/observations/current?stationId=${ pws.id }&format=json&units=e&apiKey=${ pws.apiKey }`; 106 | 107 | let data; 108 | try { 109 | data = await httpJSONRequest( currentURL ); 110 | } catch ( err ) { 111 | console.error( "Error retrieving weather information from WUnderground:", err ); 112 | throw new CodedError( ErrorCode.WeatherApiError ); 113 | } 114 | 115 | const current = data.observations[0]; 116 | 117 | const icon = forecast.daypart[0].iconCode[0]; 118 | 119 | const maxTemp = forecast.temperatureMax[0]; 120 | 121 | const weather: WeatherData = { 122 | weatherProvider: "WUnderground", 123 | temp: Math.floor( current.imperial.temp ), 124 | humidity: Math.floor( current.humidity ), 125 | wind: Math.floor( current.imperial.windSpeed ), 126 | raining: current.imperial.precipRate > 0, 127 | description: forecast.narrative[0], 128 | icon: this.getWUIconCode( (icon === null) ? -1 : icon ), //Null after 3pm 129 | 130 | region: current.country, 131 | city: "", 132 | minTemp: Math.floor( forecast.temperatureMin[0] ), 133 | maxTemp: Math.floor( (maxTemp === null ) ? current.imperial.temp : maxTemp ), //Null after 3pm 134 | precip: forecast.qpf[0] + forecast.qpfSnow[0], 135 | forecast: [] 136 | }; 137 | 138 | for ( let index = 0; index < forecast.dayOfWeek.length; index++ ) { 139 | weather.forecast.push( { 140 | temp_min: Math.floor( forecast.temperatureMin[index] ), 141 | temp_max: Math.floor( forecast.temperatureMax[index] ), 142 | precip: forecast.qpf[index] + forecast.qpfSnow[index], 143 | date: forecast.validTimeUtc[index], 144 | icon: this.getWUIconCode( forecast.daypart[0].iconCode[index] ), 145 | description: forecast.narrative[index] 146 | } ); 147 | } 148 | 149 | return weather; 150 | } 151 | 152 | public shouldCacheWateringScale(): boolean { 153 | return false; 154 | } 155 | 156 | private getWUIconCode(code: number) { 157 | const mapping = [ 158 | "50d", // Tornado 159 | "09d", // Tropical Storm 160 | "09d", // Hurricane 161 | "11d", // Strong Storms 162 | "11d", // Thunderstorms 163 | "13d", // Rain + Snow 164 | "13d", // Rain + Sleet 165 | "13d", // Wintry Mix 166 | "13d", // Freezing Drizzle 167 | "09d", // Drizzle 168 | "13d", // Freezing Rain 169 | "09d", // Showers 170 | "09d", // Rain 171 | "13d", // Flurries 172 | "13d", // Snow Showers 173 | "13d", // Blowing/Drifting Snow 174 | "13d", // Snow 175 | "13d", // Hail 176 | "13d", // Sleet 177 | "50d", // Blowing Dust/Sand 178 | "50d", // Foggy 179 | "50d", // Haze 180 | "50d", // Smoke 181 | "50d", // Breezy 182 | "50d", // Windy 183 | "13d", // Frigid/Ice Crystals 184 | "04d", // Cloudy 185 | "03n", // Mostly Cloudy (night) 186 | "03d", // Mostly Cloudy (day) 187 | "02n", // Partly Cloudy (night) 188 | "02d", // Partly Cloudy (day) 189 | "01n", // Clear night 190 | "01d", // Sunny 191 | "02n", // Mostly clear night 192 | "02d", // Mostly sunny 193 | "13d", // Rain and Hail 194 | "01d", // Hot 195 | "11d", // Isolated thunderstorms (Day) 196 | "11d", // Scattered thunderstorms (Day) 197 | "09d", // Scattered showers (Day) 198 | "09d", // Heavy rain 199 | "13d", // Scattered snow shower (Day) 200 | "13d", // Heavy snow 201 | "13d", // Blizzard 202 | "01d", // Not available 203 | "09n", // Scattered showers (Night) 204 | "13n", // Scattered snow shower (Night) 205 | "09n" // Scattered thunderstorm (Night) 206 | ]; 207 | return (code >= 0 && code < mapping.length) ? mapping[code] : "50d"; 208 | } 209 | 210 | // Fahrenheit to Grad Celcius: 211 | private F2C(fahrenheit: number): number { 212 | return (fahrenheit-32) / 1.8; 213 | } 214 | 215 | //mph to kmh: 216 | private mph2kmh(mph : number): number { 217 | return mph * 1.609344; 218 | } 219 | 220 | //inch to mm: 221 | private inch2mm(inch : number): number { 222 | return inch * 25.4; 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/routes/weatherProviders/PirateWeather.ts: -------------------------------------------------------------------------------- 1 | import { GeoCoordinates, PWS, WeatherData, WateringData } from "../../types"; 2 | import { httpJSONRequest, keyToUse, localTime } from "../weather"; 3 | import { WeatherProvider } from "./WeatherProvider"; 4 | import { approximateSolarRadiation, CloudCoverInfo } from "../adjustmentMethods/EToAdjustmentMethod"; 5 | import { CodedError, ErrorCode } from "../../errors"; 6 | import { addDays, fromUnixTime, getUnixTime, startOfDay, subDays } from "date-fns"; 7 | 8 | export default class PirateWeatherWeatherProvider extends WeatherProvider { 9 | 10 | private API_KEY: string; 11 | 12 | public constructor() { 13 | super(); 14 | this.API_KEY = process.env.PIRATEWEATHER_API_KEY; 15 | } 16 | 17 | protected async getWateringDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WateringData[] > { 18 | // The Unix timestamp of 24 hours ago. 19 | const yesterday = subDays(startOfDay(localTime(coordinates)), 1); 20 | 21 | const localKey = keyToUse(this.API_KEY, pws); 22 | 23 | // PW's timemachine API is broken currently, so we have to use the forecast API, which only gives the most recent 24 hours 24 | // const yesterdayUrl = `https://timemachine.pirateweather.net/forecast/${ localKey }/${ coordinates[ 0 ] },${ coordinates[ 1 ] },${ getUnixTime(yesterday) }?exclude=currently,minutely,alerts`; 25 | const yesterdayUrl = `https://api.pirateweather.net/forecast/${ localKey }/${ coordinates[ 0 ] },${ coordinates[ 1 ] },${ getUnixTime(subDays(new Date(), 1)) }?exclude=currently,minutely,alerts&units=ca`; 26 | 27 | let historicData; 28 | try { 29 | historicData = await httpJSONRequest( yesterdayUrl ); 30 | } catch ( err ) { 31 | console.error( "Error retrieving weather information from PirateWeather:", err ); 32 | throw new CodedError( ErrorCode.WeatherApiError ); 33 | } 34 | 35 | if ( !historicData.hourly || !historicData.hourly.data ) { 36 | throw new CodedError( ErrorCode.MissingWeatherField ); 37 | } 38 | 39 | let samples = [ 40 | ...historicData.hourly.data 41 | ]; 42 | 43 | // Fail if not enough data is available. 44 | if ( samples.length < 24 ) { 45 | throw new CodedError( ErrorCode.InsufficientWeatherData ); 46 | } 47 | 48 | //returns 48 hours (first 24 are historical so only loop those) 49 | samples = samples.slice(0,24); 50 | 51 | const cloudCoverInfo: CloudCoverInfo[] = samples.map( ( hour ): CloudCoverInfo => { 52 | const startTime = fromUnixTime(hour.time); 53 | return { 54 | startTime, 55 | endTime: addDays(startTime, 1), 56 | cloudCover: hour.cloudCover 57 | }; 58 | } ); 59 | 60 | let temp: number = 0, humidity: number = 0, precip: number = 0, 61 | minHumidity: number = undefined, maxHumidity: number = undefined; 62 | for ( const hour of samples ) { 63 | /* 64 | * If temperature or humidity is missing from a sample, the total will become NaN. This is intended since 65 | * calculateWateringScale will treat NaN as a missing value and temperature/humidity can't be accurately 66 | * calculated when data is missing from some samples (since they follow diurnal cycles and will be 67 | * significantly skewed if data is missing for several consecutive hours). 68 | */ 69 | 70 | temp += hour.temperature; 71 | const currentHumidity = hour.humidity || this.humidityFromDewPoint(hour.temperature, hour.dewPoint); 72 | humidity += currentHumidity; 73 | // This field may be missing from the response if it is snowing. 74 | precip += hour.precipAccumulation || 0; 75 | 76 | // Skip hours where humidity measurement does not exist to prevent ETo result from being NaN. 77 | if ( currentHumidity !== undefined ) { 78 | minHumidity = minHumidity < currentHumidity ? minHumidity : currentHumidity; 79 | maxHumidity = maxHumidity > currentHumidity ? maxHumidity : currentHumidity; 80 | } 81 | } 82 | 83 | return [{ 84 | weatherProvider: "PW", 85 | temp: this.celsiusToFahrenheit(temp / samples.length), 86 | humidity: humidity / samples.length * 100, 87 | precip: this.mmToInchesPerHour(precip), 88 | periodStartTime: historicData.hourly.data[ 0 ].time, 89 | minTemp: this.celsiusToFahrenheit(historicData.daily.data[ 0 ].temperatureMin), 90 | maxTemp: this.celsiusToFahrenheit(historicData.daily.data[ 0 ].temperatureMax), 91 | minHumidity: minHumidity * 100, 92 | maxHumidity: maxHumidity * 100, 93 | solarRadiation: approximateSolarRadiation( cloudCoverInfo, coordinates ), 94 | // Assume wind speed measurements are taken at 2 meters. 95 | windSpeed: this.kphToMph(historicData.daily.data[ 0 ].windSpeed) 96 | }]; 97 | } 98 | 99 | protected async getWeatherDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WeatherData > { 100 | 101 | const localKey = keyToUse( this.API_KEY, pws); 102 | 103 | const forecastUrl = `https://api.pirateweather.net/forecast/${ localKey }/${ coordinates[ 0 ] },${ coordinates[ 1 ] }?units=us&exclude=minutely,hourly,alerts`; 104 | 105 | let forecast; 106 | try { 107 | forecast = await httpJSONRequest( forecastUrl ); 108 | } catch ( err ) { 109 | console.error( "Error retrieving weather information from PirateWeather:", err ); 110 | throw "An error occurred while retrieving weather information from PirateWeather." 111 | } 112 | 113 | if ( !forecast.currently || !forecast.daily || !forecast.daily.data ) { 114 | throw "Necessary field(s) were missing from weather information returned by PirateWeather."; 115 | } 116 | 117 | const weather: WeatherData = { 118 | weatherProvider: "PirateWeather", 119 | temp: Math.floor( forecast.currently.temperature ), 120 | humidity: Math.floor( forecast.currently.humidity * 100 ), 121 | wind: Math.floor( forecast.currently.windSpeed ), 122 | raining: forecast.currently.precipIntensity > 0, 123 | description: forecast.currently.summary, 124 | icon: this.getOWMIconCode( forecast.currently.icon ), 125 | 126 | region: "", 127 | city: "", 128 | minTemp: Math.floor( forecast.daily.data[ 0 ].temperatureMin ), 129 | maxTemp: Math.floor( forecast.daily.data[ 0 ].temperatureMax ), 130 | precip: forecast.daily.data[ 0 ].precipIntensity * 24, 131 | forecast: [] 132 | }; 133 | 134 | for ( let index = 0; index < forecast.daily.data.length; index++ ) { 135 | weather.forecast.push( { 136 | temp_min: Math.floor( forecast.daily.data[ index ].temperatureMin ), 137 | temp_max: Math.floor( forecast.daily.data[ index ].temperatureMax ), 138 | precip: forecast.daily.data[ index ].precipIntensity * 24, 139 | date: forecast.daily.data[ index ].time, 140 | icon: this.getOWMIconCode( forecast.daily.data[ index ].icon ), 141 | description: forecast.daily.data[ index ].summary 142 | } ); 143 | } 144 | 145 | return weather; 146 | } 147 | 148 | public shouldCacheWateringScale(): boolean { 149 | return true; 150 | } 151 | 152 | private getOWMIconCode(icon: string) { 153 | switch(icon) { 154 | case "partly-cloudy-night": 155 | return "02n"; 156 | case "partly-cloudy-day": 157 | return "02d"; 158 | case "cloudy": 159 | return "03d"; 160 | case "fog": 161 | case "wind": 162 | return "50d"; 163 | case "sleet": 164 | case "snow": 165 | return "13d"; 166 | case "rain": 167 | return "10d"; 168 | case "clear-night": 169 | return "01n"; 170 | case "clear-day": 171 | default: 172 | return "01d"; 173 | } 174 | } 175 | 176 | private celsiusToFahrenheit(celsius: number): number { 177 | return (celsius * 9) / 5 + 32; 178 | } 179 | 180 | private mmToInchesPerHour(mmPerHour: number): number { 181 | return mmPerHour * 0.03937007874; 182 | } 183 | 184 | private kphToMph(kph: number): number { 185 | return kph * 0.621371; 186 | } 187 | 188 | //https://www.npl.co.uk/resources/q-a/dew-point-and-relative-humidity 189 | private eLn(temperature: number, a: number, b: number): number { 190 | return Math.log(611.2) + ((a * temperature) / (b + temperature)); 191 | } 192 | 193 | private eWaterLn(temperature: number): number { 194 | return this.eLn(temperature, 17.62, 243.12); 195 | } 196 | private eIceLn(temperature: number): number { 197 | return this.eLn(temperature, 22.46, 272.62); 198 | } 199 | 200 | private humidityFromDewPoint(temperature: number, dewPoint: number): number { 201 | if (isNaN(temperature)) return temperature; 202 | if (isNaN(dewPoint)) return dewPoint; 203 | 204 | let eFn: (temp: number) => number; 205 | 206 | if (temperature > 0) { 207 | eFn = (temp: number) => this.eWaterLn(temp); 208 | } else { 209 | eFn = (temp: number) => this.eIceLn(temp); 210 | } 211 | 212 | return 100 * Math.exp(eFn(dewPoint) - eFn(temperature)); 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/routes/weatherProviders/AccuWeather.ts: -------------------------------------------------------------------------------- 1 | import { GeoCoordinates, PWS, WeatherData, WateringData } from "../../types"; 2 | import { getTZ, httpJSONRequest, keyToUse } from "../weather"; 3 | import { WeatherProvider } from "./WeatherProvider"; 4 | import { approximateSolarRadiation, CloudCoverInfo } from "../adjustmentMethods/EToAdjustmentMethod"; 5 | import { CodedError, ErrorCode } from "../../errors"; 6 | import { addHours, fromUnixTime } from "date-fns"; 7 | import { tz } from "@date-fns/tz"; 8 | 9 | export default class AccuWeatherWeatherProvider extends WeatherProvider { 10 | 11 | private API_KEY: string; 12 | 13 | public constructor() { 14 | super(); 15 | this.API_KEY = process.env.ACCUWEATHER_API_KEY; 16 | } 17 | 18 | protected async getWateringDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WateringData[] > { 19 | const localKey = keyToUse(this.API_KEY, pws); 20 | 21 | const locationUrl = `https://dataservice.accuweather.com/locations/v1/cities/geoposition/search?apikey=${ localKey }&q=${ coordinates[ 0 ] },${ coordinates[ 1 ] }`; 22 | 23 | let locationData; 24 | try { 25 | locationData = await httpJSONRequest( locationUrl ); 26 | } catch ( err ) { 27 | console.error( "Error retrieving location information from AccuWeather:", err ); 28 | } 29 | 30 | const historicUrl = `http://dataservice.accuweather.com/currentconditions/v1/${ locationData.Key }/historical/24?apikey=${ localKey }&details=true`; 31 | 32 | let historicData; 33 | try { 34 | historicData = await httpJSONRequest( historicUrl ); 35 | } catch ( err ) { 36 | console.error( "Error retrieving weather information from AccuWeather:", err ); 37 | throw new CodedError( ErrorCode.WeatherApiError ); 38 | } 39 | 40 | let dataLen = historicData.length; 41 | if ( typeof dataLen !== "number" ) { 42 | throw "Necessary field(s) were missing from weather information returned by AccuWeather."; 43 | } 44 | if ( dataLen < 23 ) { 45 | throw new CodedError( ErrorCode.InsufficientWeatherData ); 46 | } 47 | 48 | const cloudCoverInfo: CloudCoverInfo[] = historicData.map( ( hour ): CloudCoverInfo => { 49 | //return empty interval if measurement does not exist 50 | const time = fromUnixTime( hour.EpochTime, {in: tz(getTZ(coordinates))} ); 51 | if(hour.CloudCover === undefined ){ 52 | return { 53 | startTime: time, 54 | endTime: time, 55 | cloudCover: 0 56 | } 57 | } 58 | return { 59 | startTime: time, 60 | endTime: addHours(time, 1), 61 | cloudCover: hour.CloudCover / 100 62 | }; 63 | } ); 64 | 65 | let temp: number = 0, humidity: number = 0, 66 | minHumidity: number = undefined, maxHumidity: number = undefined, avgWindSpeed: number = 0; 67 | for ( const hour of historicData ) { 68 | /* 69 | * If temperature or humidity is missing from a sample, the total will become NaN. This is intended since 70 | * calculateWateringScale will treat NaN as a missing value and temperature/humidity can't be accurately 71 | * calculated when data is missing from some samples (since they follow diurnal cycles and will be 72 | * significantly skewed if data is missing for several consecutive hours). 73 | */ 74 | temp += hour.Temperature.Imperial.Value; 75 | humidity += hour.RelativeHumidity; 76 | 77 | // Skip hours where humidity measurement does not exist to prevent ETo result from being NaN. 78 | if ( hour.RelativeHumidity !== undefined ) { 79 | // If minHumidity or maxHumidity is undefined, these comparisons will yield false. 80 | minHumidity = minHumidity < hour.RelativeHumidity ? minHumidity : hour.RelativeHumidity; 81 | maxHumidity = maxHumidity > hour.RelativeHumidity ? maxHumidity : hour.RelativeHumidity; 82 | } 83 | 84 | avgWindSpeed += hour.Wind.Speed.Imperial.Value || 0; 85 | } 86 | 87 | // Accuweather returns data in reverse chronological order by hour 88 | return [{ 89 | weatherProvider: "AW", 90 | temp: temp / dataLen, 91 | humidity: humidity / dataLen, 92 | precip: historicData[0].PrecipitationSummary.Past24Hours.Imperial.Value, 93 | periodStartTime: historicData[dataLen - 1].EpochTime, 94 | minTemp: historicData[0].TemperatureSummary.Past24HourRange.Minimum.Imperial.Value, 95 | maxTemp: historicData[0].TemperatureSummary.Past24HourRange.Maximum.Imperial.Value, 96 | minHumidity: minHumidity, 97 | maxHumidity: maxHumidity, 98 | solarRadiation: approximateSolarRadiation( cloudCoverInfo, coordinates ), 99 | // Assume wind speed measurements are taken at 2 meters. 100 | windSpeed: avgWindSpeed / historicData.length 101 | }]; 102 | } 103 | 104 | protected async getWeatherDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WeatherData > { 105 | const localKey = keyToUse(this.API_KEY, pws); 106 | 107 | const locationUrl = `https://dataservice.accuweather.com/locations/v1/cities/geoposition/search?apikey=${ localKey }&q=${ coordinates[ 0 ] },${ coordinates[ 1 ] }`; 108 | 109 | let locationData; 110 | try { 111 | locationData = await httpJSONRequest( locationUrl ); 112 | } catch ( err ) { 113 | console.error( "Error retrieving location information from AccuWeather:", err ); 114 | } 115 | 116 | const currentUrl = `https://dataservice.accuweather.com/currentconditions/v1/${ locationData.Key }?apikey=${ localKey }&details=true`; 117 | const forecastUrl = `https://dataservice.accuweather.com/forecasts/v1/daily/5day/${ locationData.Key }?apikey=${ localKey }&details=true`; 118 | 119 | let currentData, forecast; 120 | try { 121 | currentData = await httpJSONRequest( currentUrl ); 122 | forecast = await httpJSONRequest( forecastUrl ); 123 | } catch ( err ) { 124 | console.error( "Error retrieving weather information from AccuWeawther:", err ); 125 | throw "An error occurred while retrieving weather information from AccuWeather." 126 | } 127 | 128 | let current = currentData[0]; 129 | let daily = forecast.DailyForecasts; 130 | if ( !current || !daily || daily.length < 5) { 131 | throw "Necessary field(s) were missing from weather information returned by AccuWeather."; 132 | } 133 | 134 | const weather: WeatherData = { 135 | weatherProvider: "AccuWeather", 136 | temp: Math.floor( current.Temperature.Imperial.Value ), 137 | humidity: Math.floor( current.RelativeHumidity ), 138 | wind: Math.floor( current.Wind.Speed.Imperial.Value ), 139 | raining: current.Precip1hr.Imperial.Value > 0, 140 | description: current.WeatherText, 141 | icon: this.getOWMIconCode( current.WeatherIcon ), 142 | 143 | region: locationData.Region.EnglishName, 144 | city: locationData.EnglishName, 145 | minTemp: Math.floor( daily[ 0 ].Temperature.Minimum.Value ), 146 | maxTemp: Math.floor( daily[ 0 ].Temperature.Maximum.Value ), 147 | precip: daily[ 0 ].Day.PrecipitationIntensity, 148 | forecast: [] 149 | }; 150 | 151 | for ( let index = 0; index < daily.length; index++ ) { 152 | weather.forecast.push( { 153 | temp_min: Math.floor( daily[ index ].Temperature.Minimum.Value ), 154 | temp_max: Math.floor( daily[ index ].Temperature.Maximum.Value ), 155 | precip: daily[ index ].Day.Rain.Value + daily[ index ].Night.Rain.Value, 156 | date: daily[ index ].EpochDate, 157 | icon: this.getOWMIconCode( daily[ index ].Day.Icon ), 158 | description: daily[ index ].Day.ShortPhrase 159 | } ); 160 | } 161 | 162 | return weather; 163 | } 164 | 165 | public shouldCacheWateringScale(): boolean { 166 | return true; 167 | } 168 | 169 | // See https://developer.accuweather.com/weather-icons 170 | private getOWMIconCode(code: number) { 171 | const mapping = [ "01d", // code = 0 172 | "01d", // 1: sunny 173 | "02d", 174 | "03d", 175 | "04d", 176 | "02d", // 5: hazy sunshine 177 | "03d", // 6: mostly cloudy 178 | "03d", // 7: cloudy 179 | "03d", // 8: overcast 180 | "03d", // 9: undefined 181 | "03d", // 10: undefined 182 | "50d", // 11: fog 183 | "09d", // 12: shower 184 | "09d", // 13: mostly cloudy w/ shower 185 | "09d", // 14: partly sunny w/ shower 186 | "11d", // 15: thunderstorm 187 | "11d", // 16: mostly cloudy w/ t-storm 188 | "11d", // 17: partly summy w/ t-storm 189 | "10d", // 18: rain 190 | "13d", // 19: flurries 191 | "13d", // 20 192 | "13d", // 21 193 | "13d", // 22: snow 194 | "13d", // 23: 195 | "13d", // 24 196 | "13d", // 25 197 | "13d", // 26 198 | "13d", // 27 199 | "13d", // 28 200 | "13d", // 29 201 | "01d", // 30: hot 202 | "01d", // 31: cold 203 | "01d", // 32: windy 204 | "01n", // 33: clear (night) 205 | "02n", // 34 206 | "03n", // 35 207 | "04n", // 36 208 | "02n", // 37: hazy (night) 209 | "03n", // 38: mostly cloud (night) 210 | "09n", // 39: shower (night) 211 | "09n", // 40: shower (night) 212 | "11n", // 41: t-storm (night) 213 | "11n", // 42: t-storm (night) 214 | "13n", // 43: flurries (night) 215 | "13n", // 44: snow (night) 216 | ]; 217 | return (code>0 && code<45) ? mapping[code] : "01d"; 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/routes/weatherProviders/DWD.ts: -------------------------------------------------------------------------------- 1 | import { GeoCoordinates, WeatherData, WateringData, PWS } from "../../types"; 2 | import { getTZ, httpJSONRequest, localTime } from "../weather"; 3 | import { WeatherProvider } from "./WeatherProvider"; 4 | import { approximateSolarRadiation, CloudCoverInfo } from "../adjustmentMethods/EToAdjustmentMethod"; 5 | import { CodedError, ErrorCode } from "../../errors"; 6 | import { addDays, addHours, getUnixTime, startOfDay, subDays } from "date-fns"; 7 | import { TZDate } from "@date-fns/tz"; 8 | 9 | export default class DWDWeatherProvider extends WeatherProvider { 10 | 11 | public constructor() { 12 | super(); 13 | } 14 | 15 | protected async getWateringDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WateringData[] > { 16 | const tz = getTZ(coordinates); 17 | const currentDay = startOfDay(localTime(coordinates)); 18 | 19 | const startTimestamp = subDays(currentDay, 7).toISOString(); 20 | const endTimestamp = currentDay.toISOString(); 21 | 22 | const historicUrl = `https://api.brightsky.dev/weather?lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }&date=${ startTimestamp }&last_date=${ endTimestamp }&tz=${tz}` 23 | 24 | let historicData; 25 | try { 26 | historicData = await httpJSONRequest( historicUrl ); 27 | } catch ( err ) { 28 | console.error( "Error retrieving weather information from Bright Sky:", err ); 29 | throw new CodedError( ErrorCode.WeatherApiError ); 30 | } 31 | 32 | if ( !historicData || !historicData.weather ) { 33 | throw new CodedError( ErrorCode.MissingWeatherField ); 34 | } 35 | 36 | const hours = historicData.weather; 37 | 38 | // Fail if not enough data is available. 39 | // There will only be 23 samples on the day that daylight saving time begins. 40 | if ( hours.length < 23 ) { 41 | throw new CodedError( ErrorCode.InsufficientWeatherData ); 42 | } 43 | 44 | // Cut down to 24 hour sections 45 | hours.length -= hours.length % 24; 46 | const daysInHours = []; 47 | for ( let i = 0; i < hours.length; i+=24 ){ 48 | daysInHours.push(hours.slice(i, i+24)); 49 | } 50 | 51 | const data = []; 52 | 53 | for(let i = 0; i < daysInHours.length; i++){ 54 | const cloudCoverInfo: CloudCoverInfo[] = daysInHours[i].map( ( hour ): CloudCoverInfo => { 55 | const startTime = new TZDate(hour.timestamp, tz); 56 | const result : CloudCoverInfo = { 57 | startTime, 58 | endTime: addHours(startTime, 1), 59 | cloudCover: hour.cloud_cover / 100.0, 60 | }; 61 | 62 | return result; 63 | } ); 64 | 65 | let temp: number = 0, humidity: number = 0, precip: number = 0, 66 | minHumidity: number = undefined, maxHumidity: number = undefined, 67 | minTemp: number = undefined, maxTemp: number = undefined, wind: number = 0; 68 | for ( const hour of daysInHours[i] ) { 69 | /* 70 | * If temperature or humidity is missing from a sample, the total will become NaN. This is intended since 71 | * calculateWateringScale will treat NaN as a missing value and temperature/humidity can't be accurately 72 | * calculated when data is missing from some samples (since they follow diurnal cycles and will be 73 | * significantly skewed if data is missing for several consecutive hours). 74 | */ 75 | 76 | temp += hour.temperature; 77 | humidity += hour.relative_humidity; 78 | // This field may be missing from the response if it is snowing. 79 | precip += hour.precipitation || 0; 80 | 81 | minTemp = minTemp < hour.temperature ? minTemp : hour.temperature; 82 | maxTemp = maxTemp > hour.temperature ? maxTemp : hour.temperature; 83 | wind += hour.wind_speed; 84 | 85 | // Skip hours where humidity does not exist to prevent ETo from being NaN. 86 | if ( hour.relative_humidity === undefined || hour.relative_humidity === null) 87 | continue; 88 | // If minHumidity or maxHumidity is undefined, these comparisons will yield false. 89 | minHumidity = minHumidity < hour.relative_humidity ? minHumidity : hour.relative_humidity; 90 | maxHumidity = maxHumidity > hour.relative_humidity ? maxHumidity : hour.relative_humidity; 91 | } 92 | 93 | const length = daysInHours[i].length; 94 | 95 | const result : WateringData = { 96 | weatherProvider: "DWD", 97 | temp: this.C2F(temp / length), 98 | humidity: humidity / length, 99 | precip: this.mm2inch(precip), 100 | periodStartTime: getUnixTime(new TZDate(daysInHours[i][0].timestamp)), 101 | minTemp: this.C2F(minTemp), 102 | maxTemp: this.C2F(maxTemp), 103 | minHumidity: minHumidity, 104 | maxHumidity: maxHumidity, 105 | solarRadiation: approximateSolarRadiation( cloudCoverInfo, coordinates ), 106 | // Assume wind speed measurements are taken at 2 meters. 107 | windSpeed: this.kmh2mph(wind / length) 108 | } 109 | 110 | if ( minTemp === undefined || maxTemp === undefined || minHumidity === undefined || maxHumidity === undefined || result.solarRadiation === undefined || wind === undefined || precip === undefined ) { 111 | throw "Information missing from BrightSky."; 112 | } 113 | 114 | data.push(result); 115 | } 116 | 117 | return data.reverse(); 118 | } 119 | 120 | protected async getWeatherDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WeatherData > { 121 | const tz = getTZ(coordinates); 122 | 123 | const currentUrl = `https://api.brightsky.dev/current_weather?lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }&tz=${tz}`; 124 | 125 | let current; 126 | try { 127 | current = await httpJSONRequest( currentUrl ); 128 | } catch ( err ) { 129 | console.error( "Error retrieving weather information from Bright Sky:", err ); 130 | throw "An error occurred while retrieving weather information from Bright Sky." 131 | } 132 | 133 | if ( !current || !current.weather ) { 134 | throw "Necessary field(s) were missing from weather information returned by Bright Sky."; 135 | } 136 | 137 | const weather: WeatherData = { 138 | weatherProvider: "DWD", 139 | temp: this.C2F(current.weather.temperature), 140 | humidity: current.weather.relative_humidity, 141 | wind: this.kmh2mph(current.weather.wind_speed_30), 142 | raining: current.weather.precipitation_60 > 0, 143 | description: current.weather.condition, 144 | icon: this.getOWMIconCode( current.weather.icon ), 145 | 146 | region: "", 147 | city: current.sources[0].station_name, 148 | minTemp: 0, 149 | maxTemp: 0, 150 | precip: 0, 151 | forecast: [], 152 | }; 153 | 154 | const local = localTime(coordinates); 155 | 156 | for ( let day = 0; day < 7; day++ ) { 157 | 158 | const date = addDays(local, day); 159 | 160 | const forecastUrl = `https://api.brightsky.dev/weather?lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }&date=${ date.toISOString() }`; 161 | 162 | let forecast; 163 | try { 164 | forecast = await httpJSONRequest( forecastUrl ); 165 | } catch ( err ) { 166 | console.error( "Error retrieving weather information from Bright Sky:", err ); 167 | throw "An error occurred while retrieving weather information from Bright Sky." 168 | } 169 | if ( !forecast || !forecast.weather ) { 170 | throw "Necessary field(s) were missing from weather information returned by Bright Sky."; 171 | } 172 | 173 | let minTemp: number = undefined, maxTemp: number = undefined, precip: number = 0; 174 | let condition: string = "dry", icon: string = "", condIdx = 0; 175 | const allowed = "dry.fog.rain.sleet.snow.hail.thunderstorm"; 176 | for ( const hour of forecast.weather ) { 177 | minTemp = minTemp < hour.temperature ? minTemp : hour.temperature; 178 | maxTemp = maxTemp > hour.temperature ? maxTemp : hour.temperature; 179 | precip += hour.precipitation; 180 | let idx: number = allowed.indexOf(hour.condition); 181 | if ( idx > condIdx ) { 182 | condIdx = idx; 183 | condition = hour.condition; 184 | icon = hour.icon; 185 | } 186 | } 187 | if ( day == 0 ) { 188 | weather.minTemp = this.C2F(minTemp); 189 | weather.maxTemp = this.C2F(maxTemp); 190 | weather.precip = this.mm2inch(precip); 191 | } 192 | weather.forecast.push( { 193 | temp_min: this.C2F(minTemp), 194 | temp_max: this.C2F(maxTemp), 195 | precip: this.mm2inch(precip), 196 | date: getUnixTime(date), 197 | icon: this.getOWMIconCode( icon ), 198 | description: condition, 199 | } ); 200 | } 201 | 202 | return weather; 203 | } 204 | 205 | public shouldCacheWateringScale(): boolean { 206 | return false; 207 | } 208 | 209 | private getOWMIconCode(icon: string) { 210 | switch(icon) { 211 | case "partly-cloudy-night": 212 | return "02n"; 213 | case "partly-cloudy-day": 214 | return "02d"; 215 | case "cloudy": 216 | return "03d"; 217 | case "fog": 218 | case "wind": 219 | return "50d"; 220 | case "sleet": 221 | case "snow": 222 | return "13d"; 223 | case "rain": 224 | return "10d"; 225 | case "clear-night": 226 | return "01n"; 227 | case "clear-day": 228 | default: 229 | return "01d"; 230 | } 231 | } 232 | 233 | //Grad Celcius to Fahrenheit: 234 | private C2F(celsius: number): number { 235 | return celsius * 1.8 + 32; 236 | } 237 | 238 | //kmh to mph: 239 | private kmh2mph(kmh : number): number { 240 | return kmh / 1.609344; 241 | } 242 | 243 | //mm to inch: 244 | private mm2inch(mm : number): number { 245 | return mm / 25.4; 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /docs/local-installation.md: -------------------------------------------------------------------------------- 1 | **NOTE**: this document is being re-written. The information below regarding how to install, run, and use the weather service on a Rapsberry Pi has been replaced by the new `README.md` at the root folder. The information about how to use your own Personal Weather Station (PWS) is still relevant and will be updated soon. 2 | 3 | ## Connecting your own Personal Weather Station (PWS) 4 | 5 | If you own a PWS and are running a local instance of the Weather Service then you may be able to send the data directly from your PWS to the Weather Service avoiding any "cloud" based services. The weather data can then be used by the Weather Service to calculate Zimmerman based watering levels. 6 | 7 | ### Options for PWS Owners 8 | 9 | **1 ) PWS supporting RESTfull output** 10 | 11 | Some PWS allow the user to specify a `GET request` to send weather observations onto a local service for processing. For example, the MeteoBridge Pro allows for requests to be specified in a custom template that translates the PWS weather values and units into a format that the local Weather Service can accept. If available, the user documentation for the PWS should detail how to configure a custom GET request. 12 | 13 | For more information on the RESTfull protocol click [here](pws-protocol.md) 14 | 15 | **2 ) Networked PWS that support Weather Underground** 16 | 17 | Many PWS already support the Weather Underground format and can be connected to the user's home network to send data directly to the WU cloud service. For these PWS, it is possible to physically intercept the data stream heading to the WU cloud and redirect it to the Weather Service server instead. 18 | 19 | To do this intercepting, you place a physical device - such as a Raspberry Pi - in-between the PWS and the home network. It is this "man-in-the-middle" device that will look for information heading from the PWS toward the WU cloud and redirect that information to the local Weather Service. 20 | 21 | For more information on configuring a Raspberry Pi Zero W to act as a "Man In The Middle" solution follow these links: 22 | - If you have a PWS that connects to your home network using an ethernet cable then click [here](man-in-middle.md) 23 | - If you have a PWS that connects to your home network via wifi then click [here](wifi-hotspot.md) 24 | 25 | **3 ) PWS Supported By WeeWX** 26 | 27 | The WeeWX project provides a mechanism for OpenSprinkler owners to capture the data from many different manufacturer's PWS and to both store the information locally and to publish the data to a number of destinations. OpenSprinkler owners can use this solution to send their PWS weather observations onto a local Weather Service server. 28 | 29 | For more information on the "WeeWX Solution" click [here](weewx.md) 30 | 31 | **4 ) Solutions for specific PWS (provided by OpenSprinkler Forum members)** 32 | 33 | - Davis Vantage: a solution for this PWS has been kindly provided by @rmloeb [here](davis-vantage.md) 34 | - Netatmo: instructions for configuring this PWS have been greatfully provided by @franzstein [here](netatmo.md) 35 | 36 | ## Installating a Local Weather Service onto a Raspberry Pi 37 | 38 | NOTE: the information below is outdated and mostly replaced by the current README.md at the root folder. 39 | 40 | **Step 1:** Download and install Node.js onto the Raspberry Pi so that you can run the OpenSprinkler weather server locally. The version of Node.js to install is dependent on your model of Raspberry Pi. Note that you can run the command ```uname -m``` on your Raspberry Pi to help identify the chipset that is being used. 41 | 42 | *For Raspberry Pi 2 or Pi 3 models that are based on the newer ARMv7 and ARMv8 chip* 43 | ``` 44 | pi@OSPi:~ $ curl -sL https://deb.nodesource.com/setup_11.x | sudo -E bash -l 45 | pi@OSPi:~ $ sudo apt-get install -y nodejs 46 | ``` 47 | 48 | *For Raspberry Pi Model A, B, B+, Zero and Compute Module based on the older ARMv6 chip, the process is slightly more convoluted* 49 | ``` 50 | pi@OSPi:~ $ wget https://nodejs.org/dist/v11.4.0/node-v11.4.0-linux-armv6l.tar.gz 51 | pi@OSPi:~ $ tar -xvf node-v11.4.0-linux-armv6l.tar.gz 52 | pi@OSPi:~ $ cd node-v11.4.0-linux-armv6l 53 | pi@OSPi:~ $ sudo cp -R * /usr/local/ 54 | pi@OSPi:~ $ cd .. 55 | pi@OSPi:~ $ rm -rf node-v11.4.0-linux-armv6l 56 | pi@OSPi:~ $ rm node-v11.4.0-linux-armv6l.tar.gz 57 | 58 | ``` 59 | 60 | **Step 2:** Download the OpenSprinkler Weather Service repository to your Raspberry Pi so that you can run a local version of the service: 61 | 62 | ``` 63 | pi@OSPi:~ $ git clone https://github.com/OpenSprinkler/OpenSprinkler-Weather.git weather 64 | ``` 65 | 66 | **Step 3:** Install all of the dependencies using the Node Package Manager, `npm`, from within the weather project directory and transpile the TypeScript files to JavaScript: 67 | ``` 68 | pi@OSPi:~ $ cd weather 69 | pi@OSPi:~/weather $ npm install 70 | pi@OSPi:~/weather $ npm run build 71 | ``` 72 | **Step 4:** Configure the weather server to use either the OpenWeatherMap API or the Dark Sky API 73 | 74 | * **Step 4a:** If you want to use the Open Weather Map API, go to `https://openweathermap.org/appid` to register with OpenWeatherMaps and obtain an API key that is needed to request weather information. 75 | 76 | * **Step 4b:** If you want to use the Dark Sky API, go to `https://darksky.net/dev` to register with Dark Sky and obtain an API key that is needed to request weather information. 77 | 78 | * **Step 4c:** If you want just want to use your PWS for weather information then you dont need to register for either Open Weather Map nor DarkSky. 79 | 80 | **Step 5:** The file `.env` is used by the weather server to specify the interface and port to listen on for requests coming from your OpenSprinkler device. You need to create a new `.env` file and enter some configuration details. 81 | ``` 82 | pi@OSPi:~/weather $ nano .env 83 | ``` 84 | 85 | Add the following lines to the .env file so that the weather server is configured to listen for weather requests. Using 0.0.0.0 as the host interfaces allows you to access the service from another machine to test. Alternatively, set HOST to “localhost” if you want to limit weather service access to only applications running on the local machine. 86 | 87 | Note: if you are using OS then you must set `PORT=80` as this cannot be changed on the OS device. If using OSPi or OSBo then you can set `PORT` to any unused value. 88 | 89 | ``` 90 | HOST=0.0.0.0 91 | PORT=3000 92 | ``` 93 | 94 | * **Step 5a:** If you registered for the OWM API then also add the following two lines to the .env file: 95 | ``` 96 | WEATHER_PROVIDER=OWM 97 | OWM_API_KEY= 98 | ``` 99 | 100 | * **Step 5b:** If you registered for the Dark Sky API then also add these two lines to the .env file: 101 | ``` 102 | WEATHER_PROVIDER=DarkSky 103 | DARKSKY_API_KEY= 104 | ``` 105 | 106 | * **Step 5c:** If you wanted to use your PWS information then make sure to add two lines to the .env file: 107 | ``` 108 | WEATHER_PROVIDER=local 109 | PWS=WU 110 | LOCAL_PERSISTENCE=true #It's advisable to use this option which logs data to observations.json every 30 minutes and maintains data accross reboots 111 | ``` 112 | 113 | * **Step 5d:** If you registered for the Apple WeatherKit API then also add these two lines to the .env file: 114 | ``` 115 | WEATHER_PROVIDER=Apple 116 | WEATHERKIT_API_KEY= 117 | ``` 118 | 119 | **Step 6:** Setup the Weather Server to start whenever the Raspberry Pi boots up using the built-in service manager: 120 | 121 | ``` 122 | pi@OSPi:~/weather $ sudo nano /etc/systemd/system/weather.service 123 | ``` 124 | 125 | Cut and paste the following lines into the weather.service file: 126 | 127 | ``` 128 | [Unit] 129 | Description=OpenSprinkler Weather Server 130 | 131 | [Service] 132 | ExecStart=/usr/local/bin/npm start 133 | WorkingDirectory=/home/pi/weather 134 | Restart=always 135 | RestartSec=10 136 | 137 | [Install] 138 | WantedBy=multi-user.target 139 | ``` 140 | Save the file and enable/start the weather service 141 | 142 | ``` 143 | pi@OSPi:~/weather $ sudo systemctl enable weather.service 144 | pi@OSPi:~/weather $ sudo systemctl start weather.service 145 | pi@OSPi:~/weather $ systemctl status weather.service 146 | ``` 147 | 148 | The final line above checks that the service has been started and you should see the service marked as running. 149 | 150 | **Step 7:** You can now test that the service is running correctly from a Web Browser by navigating to the service (note: make sure to use the PORT number specified in the `.env` file above, e.g. 3000 for OSPi or 80 for OS devices): 151 | 152 | ``` 153 | http:/// 154 | ``` 155 | You should see the text "OpenSprinkler Weather Service" appear in your browser in response. 156 | 157 | Next, you can use the following request to see the watering level that the Weather Service calculates. Note: to be consistent, change the values of h, t and r to the % weightings and bh (as a %), bt (in F), bp (in inches) to the offsets from the Zimmerman config page in App. 158 | 159 | ``` 160 | http:///weather1.py?loc=50,1&wto="h":100,"t":100,"r":100,"bh":70,"bt":59,"br":0 161 | ``` 162 | 163 | This will return a response similar to below with the `scale` value equating to the watering level and `rawData` reflecting the temp (F), humidity (%) and daily rainfall (inches) used in the zimmerman calc. 164 | ``` 165 | &scale=20&rd=-1&tz=48&sunrise=268&sunset=1167&eip=3232235787&rawData={"h":47,"p":0,"t":54.4,"raining":0} 166 | ``` 167 | 168 | **Step 8:** You will now need to configure your OpenSprinkler device to use the local version of the Weather Service rather than the Cloud version. On a web browser, go to `http://:80/su` if you have an OS device or `http://:8080/su` for OSPi/OSBo devices to set the Weather Service IP and PORT number. 169 | 170 | OpenSprinkler should now be connected to your local Weather Service for calculating rain delay and watering levels. 171 | 172 | -------------------------------------------------------------------------------- /src/routes/weatherProviders/OpenMeteo.ts: -------------------------------------------------------------------------------- 1 | import { GeoCoordinates, WeatherData, WateringData, PWS } from "../../types"; 2 | import { getTZ, httpJSONRequest, localTime } from "../weather"; 3 | import { WeatherProvider } from "./WeatherProvider"; 4 | import { CodedError, ErrorCode } from "../../errors"; 5 | import { format, getUnixTime, startOfDay, subDays } from "date-fns"; 6 | 7 | export default class OpenMeteoWeatherProvider extends WeatherProvider { 8 | 9 | /** 10 | * Api Docs from here: https://open-meteo.com/en/docs 11 | */ 12 | public constructor() { 13 | super(); 14 | } 15 | 16 | protected async getWateringDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WateringData[] > { 17 | const tz = getTZ(coordinates); 18 | 19 | const currentDay = startOfDay(localTime(coordinates)); 20 | 21 | const startTimestamp = format(subDays(currentDay, 7), "yyyy-MM-dd"); 22 | const endTimestamp = format(currentDay, "yyyy-MM-dd"); 23 | 24 | 25 | const historicUrl = `https://api.open-meteo.com/v1/forecast?latitude=${ coordinates[ 0 ] }&longitude=${ coordinates[ 1 ] }&hourly=temperature_2m,relativehumidity_2m,precipitation,direct_radiation,windspeed_10m&temperature_unit=fahrenheit&windspeed_unit=mph&precipitation_unit=inch&start_date=${startTimestamp}&end_date=${endTimestamp}&timezone=${tz}&timeformat=unixtime`; 26 | 27 | let historicData; 28 | try { 29 | historicData = await httpJSONRequest( historicUrl ); 30 | } catch ( err ) { 31 | console.error( "Error retrieving weather information from OpenMeteo:", err ); 32 | throw new CodedError( ErrorCode.WeatherApiError ); 33 | } 34 | 35 | if ( !historicData || !historicData.hourly ) { 36 | throw new CodedError( ErrorCode.MissingWeatherField ); 37 | } 38 | 39 | // Cut data down to 7 days previous (midnight to midnight) 40 | const start = getUnixTime(startOfDay(localTime(coordinates))); 41 | 42 | const historicCutoff = historicData.hourly.time.findIndex( function( time ) { 43 | return time >= start; 44 | } ); 45 | 46 | for (const arr in historicData.hourly) { 47 | historicData.hourly[arr].length = historicCutoff - historicCutoff % 24; 48 | } 49 | 50 | const data: WateringData[] = []; 51 | 52 | for(let i = 0; i < 7; i++){ // 53 | let temp: number = 0, humidity: number = 0, precip: number = 0, 54 | minHumidity: number = undefined, maxHumidity: number = undefined, 55 | minTemp: number = undefined, maxTemp: number = undefined, 56 | wind: number = 0, solar: number = 0; 57 | 58 | for (let index = i*24; index < (i+1)*24; index++ ) { 59 | temp += historicData.hourly.temperature_2m[index]; 60 | humidity += historicData.hourly.relativehumidity_2m[index]; 61 | precip += historicData.hourly.precipitation[index] || 0; 62 | 63 | minTemp = minTemp < historicData.hourly.temperature_2m[index] ? minTemp : historicData.hourly.temperature_2m[index]; 64 | maxTemp = maxTemp > historicData.hourly.temperature_2m[index] ? maxTemp : historicData.hourly.temperature_2m[index]; 65 | 66 | if (historicData.hourly.windspeed_10m[index] > wind) 67 | wind = historicData.hourly.windspeed_10m[index]; 68 | 69 | minHumidity = minHumidity < historicData.hourly.relativehumidity_2m[index] ? minHumidity : historicData.hourly.relativehumidity_2m[index]; 70 | maxHumidity = maxHumidity > historicData.hourly.relativehumidity_2m[index] ? maxHumidity : historicData.hourly.relativehumidity_2m[index]; 71 | 72 | solar += historicData.hourly.direct_radiation[index]; 73 | } 74 | 75 | const result: WateringData = { 76 | weatherProvider: "OpenMeteo", 77 | temp: temp / 24, 78 | humidity: humidity / 24, 79 | precip: precip, 80 | periodStartTime: historicData.hourly.time[i*24], 81 | minTemp: minTemp, 82 | maxTemp: maxTemp, 83 | minHumidity: minHumidity, 84 | maxHumidity: maxHumidity, 85 | solarRadiation: solar / 1000, // API gives in Watts 86 | windSpeed: wind 87 | } 88 | 89 | data.push(result); 90 | } 91 | 92 | return data.reverse(); 93 | } 94 | 95 | protected async getWeatherDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WeatherData > { 96 | const timezone = getTZ(coordinates); 97 | 98 | const currentUrl = `https://api.open-meteo.com/v1/forecast?latitude=${ coordinates[ 0 ] }&longitude=${ coordinates[ 1 ] }&timezone=${ timezone }&daily=weathercode,temperature_2m_max,temperature_2m_min,precipitation_sum,windspeed_10m_max¤t_weather=true&temperature_unit=fahrenheit&windspeed_unit=mph&precipitation_unit=inch&timeformat=unixtime`; 99 | 100 | let current; 101 | try { 102 | current = await httpJSONRequest( currentUrl ); 103 | } catch ( err ) { 104 | console.error( "Error retrieving weather information from OpenMeteo:", err ); 105 | throw "An error occurred while retrieving weather information from OpenMeteo." 106 | } 107 | 108 | if ( !current || !current.daily || !current.current_weather ) { 109 | throw "Necessary field(s) were missing from weather information returned by OpenMeteo."; 110 | } 111 | 112 | const weather: WeatherData = { 113 | weatherProvider: "OpenMeteo", 114 | temp: current.current_weather.temperature, 115 | humidity: 0, 116 | wind: current.current_weather.windspeed, 117 | raining: current.daily.precipitation_sum[0] > 0, 118 | description: this.getWMOIconCode(current.current_weather.weathercode).desc, 119 | icon: this.getWMOIconCode(current.current_weather.weathercode).icon, 120 | 121 | region: "", 122 | city: "", 123 | minTemp: current.daily.temperature_2m_min[0], 124 | maxTemp: current.daily.temperature_2m_max[0], 125 | precip: current.daily.precipitation_sum[0], 126 | forecast: [], 127 | }; 128 | 129 | for ( let day = 0; day < current.daily.time.length; day++ ) { 130 | weather.forecast.push( { 131 | temp_min: current.daily.temperature_2m_min[day], 132 | temp_max: current.daily.temperature_2m_max[day], 133 | precip: current.daily.precipitation_sum[day], 134 | date: current.daily.time[day], 135 | icon: this.getWMOIconCode( current.daily.weathercode[day] ).icon, 136 | description: this.getWMOIconCode( current.daily.weathercode[day] ).desc, 137 | } ); 138 | } 139 | 140 | return weather; 141 | } 142 | 143 | public shouldCacheWateringScale(): boolean { 144 | return true; 145 | } 146 | 147 | /** 148 | * See https://open-meteo.com/en/docs 149 | * @param code 150 | * @returns 151 | */ 152 | private getWMOIconCode(code: number) { 153 | switch(code) { 154 | case 0: 155 | //0 Clear sky 156 | return {"icon": "01d", desc: "Clear Sky"}; 157 | case 1: 158 | //1, 2, 3 Mainly clear, partly cloudy, and overcast 159 | return {"icon": "02d", desc: "Mainly cloudy"}; 160 | case 2: 161 | return {"icon": "03d", desc: "Partly cloudy"}; 162 | case 3: 163 | return {"icon": "04d", desc: "Overcast"}; 164 | case 45: 165 | //45, 48 Fog and depositing rime fog 166 | return {"icon": "50d", desc: "Fog"}; 167 | case 48: 168 | return {"icon": "50d", desc: "Deposing rime fog"}; 169 | case 51: 170 | //51, 53, 55 Drizzle: Light, moderate, and dense intensity 171 | return {"icon": "50d", desc: "Drizzle: light"}; 172 | case 53: 173 | return {"icon": "50d", desc: "Drizzle: moderate"}; 174 | case 55: 175 | return {"icon": "50d", desc: "Drizzle: dense"}; // or "09d"? 176 | case 56: 177 | //56, 57 Freezing Drizzle: Light and dense intensity 178 | return {"icon": "50d", desc: "Freezing Drizzle: light"}; 179 | case 57: 180 | return {"icon": "50d", desc: "Freezing Drizzle: dense"}; // or "09d"? 181 | case 61: 182 | //61, 63, 65 Rain: Slight, moderate and heavy intensity 183 | return {"icon": "10d", desc: "Rain: slight"}; 184 | case 63: 185 | return {"icon": "09d", desc: "Rain: moderate"}; 186 | case 65: 187 | return {"icon": "11d", desc: "Rain: heavy"}; 188 | case 66: 189 | //66, 67 Freezing Rain: Light and heavy intensity 190 | return {"icon": "09d", desc: "Freezing Rain: light"}; 191 | case 67: 192 | return {"icon": "11d", desc: "Freezing Rain: heavy"}; 193 | case 71: 194 | //71, 73, 75 Snow fall: Slight, moderate, and heavy intensity 195 | return {"icon": "13d", desc: "Snow fall: slight"}; 196 | case 73: 197 | return {"icon": "13d", desc: "Snow fall: moderate"}; 198 | case 75: 199 | return {"icon": "13d", desc: "Snow fall: heavy"}; 200 | case 77: 201 | //77 Snow grains 202 | return {"icon": "13d", desc: "Snow grains"}; 203 | case 80: 204 | //80, 81, 82 Rain showers: Slight, moderate, and violent 205 | return {"icon": "11d", desc: "Rain showers: slight"}; 206 | case 81: 207 | return {"icon": "11d", desc: "Rain showers: moderate"}; 208 | case 82: 209 | return {"icon": "11d", desc: "Rain showers: violent"}; 210 | case 85: 211 | //85, 86 Snow showers slight and heavy 212 | return {"icon": "13d", desc: "Snow showers: slight"}; 213 | case 86: 214 | return {"icon": "13d", desc: "Snow showers: heavy"}; 215 | case 95: 216 | //95 Thunderstorm: Slight or moderate 217 | return {"icon": "11d", desc: "Thunderstorm: Slight or moderate"}; 218 | case 96: 219 | //96, 99 Thunderstorm with slight and heavy hail 220 | return {"icon": "13d", desc: "Thunderstorm: slight hail"}; 221 | case 99: 222 | return {"icon": "13d", desc: "Thunderstorm: heavy hail"}; // or "11d"? 223 | default: 224 | return {"icon": "01d", desc: "Clear sky"}; 225 | } 226 | } 227 | 228 | //Grad Celcius to Fahrenheit: 229 | private C2F(celsius: number): number { 230 | return celsius * 1.8 + 32; 231 | } 232 | 233 | //kmh to mph: 234 | private kmh2mph(kmh : number): number { 235 | return kmh / 1.609344; 236 | } 237 | 238 | //mm to inch: 239 | private mm2inch(mm : number): number { 240 | return mm / 25.4; 241 | } 242 | 243 | // Fahrenheit to Grad Celcius: 244 | private F2C(fahrenheit: number): number { 245 | return (fahrenheit-32) / 1.8; 246 | } 247 | 248 | //mph to kmh: 249 | private mph2kmh(mph : number): number { 250 | return mph * 1.609344; 251 | } 252 | 253 | //inch to mm: 254 | private inch2mm(inch : number): number { 255 | return inch * 25.4; 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /.github/workflows/build-ci.yml: -------------------------------------------------------------------------------- 1 | # Based heavily on the docker build action from immich (https://github.com/immich-app/immich/) 2 | name: Docker 3 | 4 | on: 5 | workflow_dispatch: 6 | push: 7 | branches: [master] 8 | pull_request: 9 | release: 10 | types: [published] 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | permissions: {} 17 | 18 | jobs: 19 | pre-job: 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | outputs: 24 | owner: ${{ steps.lower-owner.outputs.owner }} 25 | repository: ${{ steps.lower-repo.outputs.repository }} 26 | steps: 27 | - id: lower-repo 28 | name: Repository to lowercase 29 | shell: bash 30 | run: | 31 | REPO="${{ github.event.repository.name }}" 32 | echo "repository=${REPO,,}" >> $GITHUB_OUTPUT 33 | 34 | - id: lower-owner 35 | name: Owner to lowercase 36 | shell: bash 37 | run: | 38 | echo "owner=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_OUTPUT 39 | 40 | retag_weather: 41 | name: Re-Tag Weather 42 | needs: pre-job 43 | permissions: 44 | contents: read 45 | packages: write 46 | runs-on: ubuntu-latest 47 | strategy: 48 | matrix: 49 | suffix: [''] 50 | steps: 51 | - name: Login to GitHub Container Registry 52 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 53 | with: 54 | registry: ghcr.io 55 | username: ${{ needs.pre-job.outputs.owner }} 56 | password: ${{ secrets.GITHUB_TOKEN }} 57 | - name: Re-tag image 58 | env: 59 | REGISTRY_NAME: 'ghcr.io' 60 | REPOSITORY: ${{ needs.pre-job.outputs.owner }}/weather-server 61 | TAG_OLD: master${{ matrix.suffix }} 62 | TAG_PR: ${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }} 63 | TAG_COMMIT: commit-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }}${{ matrix.suffix }} 64 | run: | 65 | docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_PR}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}" 66 | docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_COMMIT}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}" 67 | 68 | build_and_push_weather: 69 | name: Build and Push Weather Server 70 | runs-on: ${{ matrix.runner }} 71 | permissions: 72 | contents: read 73 | packages: write 74 | needs: pre-job 75 | env: 76 | image: weather-server 77 | context: . 78 | file: Dockerfile 79 | GHCR_REPO: ghcr.io/${{ needs.pre-job.outputs.owner }}/weather-server 80 | strategy: 81 | fail-fast: false 82 | matrix: 83 | include: 84 | - platform: linux/amd64 85 | runner: ubuntu-latest 86 | - platform: linux/arm64 87 | runner: ubuntu-24.04-arm 88 | steps: 89 | - name: Prepare 90 | run: | 91 | platform=${{ matrix.platform }} 92 | echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV 93 | 94 | - name: Checkout 95 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 96 | with: 97 | persist-credentials: false 98 | 99 | - name: Set up Docker Buildx 100 | uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3 101 | 102 | - name: Login to GitHub Container Registry 103 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 104 | if: ${{ !github.event.pull_request.head.repo.fork }} 105 | with: 106 | registry: ghcr.io 107 | username: ${{ needs.pre-job.outputs.owner }} 108 | password: ${{ secrets.GITHUB_TOKEN }} 109 | 110 | - name: Generate cache key suffix 111 | env: 112 | REF: ${{ github.ref_name }} 113 | run: | 114 | if [[ "${{ github.event_name }}" == "pull_request" ]]; then 115 | echo "CACHE_KEY_SUFFIX=pr-${{ github.event.number }}" >> $GITHUB_ENV 116 | else 117 | SUFFIX=$(echo "${REF}" | sed 's/[^a-zA-Z0-9]/-/g') 118 | echo "CACHE_KEY_SUFFIX=${SUFFIX}" >> $GITHUB_ENV 119 | fi 120 | 121 | - name: Generate cache target 122 | id: cache-target 123 | run: | 124 | if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then 125 | # Essentially just ignore the cache output (forks can't write to registry cache) 126 | echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT 127 | else 128 | echo "cache-to=type=registry,ref=${GHCR_REPO}-build-cache:${PLATFORM_PAIR}-${CACHE_KEY_SUFFIX},mode=max,compression=zstd" >> $GITHUB_OUTPUT 129 | fi 130 | 131 | - name: Generate docker image tags 132 | id: meta 133 | uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5 134 | env: 135 | DOCKER_METADATA_PR_HEAD_SHA: 'true' 136 | 137 | - name: Build and push image 138 | id: build 139 | uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 140 | with: 141 | context: ${{ env.context }} 142 | file: ${{ env.file }} 143 | platforms: ${{ matrix.platform }} 144 | labels: ${{ steps.meta.outputs.labels }} 145 | cache-to: ${{ steps.cache-target.outputs.cache-to }} 146 | cache-from: | 147 | type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-${{ env.CACHE_KEY_SUFFIX }} 148 | type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-master 149 | outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=${{ !github.event.pull_request.head.repo.fork }} 150 | build-args: | 151 | DEVICE=cpu 152 | BUILD_ID=${{ github.run_id }} 153 | BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.metadata.outputs.tags }} 154 | BUILD_SOURCE_REF=${{ github.ref_name }} 155 | BUILD_SOURCE_COMMIT=${{ github.sha }} 156 | 157 | - name: Export digest 158 | run: | 159 | mkdir -p ${{ runner.temp }}/digests 160 | digest="${{ steps.build.outputs.digest }}" 161 | touch "${{ runner.temp }}/digests/${digest#sha256:}" 162 | 163 | - name: Upload digest 164 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 165 | with: 166 | name: weather-digests-${{ env.PLATFORM_PAIR }} 167 | path: ${{ runner.temp }}/digests/* 168 | if-no-files-found: error 169 | retention-days: 1 170 | 171 | merge_weather: 172 | name: Merge & Push Weather Server 173 | runs-on: ubuntu-latest 174 | permissions: 175 | contents: read 176 | actions: read 177 | packages: write 178 | env: 179 | GHCR_REPO: ghcr.io/${{ needs.pre-job.outputs.owner }}/weather-server 180 | needs: 181 | - build_and_push_weather 182 | - pre-job 183 | steps: 184 | - name: Download digests 185 | uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 186 | with: 187 | path: ${{ runner.temp }}/digests 188 | pattern: weather-digests-* 189 | merge-multiple: true 190 | 191 | - name: Login to GHCR 192 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 193 | with: 194 | registry: ghcr.io 195 | username: ${{ needs.pre-job.outputs.owner }} 196 | password: ${{ secrets.GITHUB_TOKEN }} 197 | 198 | - name: Set up Docker Buildx 199 | uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3 200 | 201 | - name: Generate docker image tags 202 | id: meta 203 | uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5 204 | env: 205 | DOCKER_METADATA_PR_HEAD_SHA: 'true' 206 | with: 207 | flavor: | 208 | # Disable latest tag 209 | latest=false 210 | suffix=${{ matrix.suffix }} 211 | images: | 212 | name=${{ env.GHCR_REPO }} 213 | tags: | 214 | # Tag with branch name 215 | type=ref,event=branch 216 | # Tag with pr-number 217 | type=ref,event=pr 218 | # Tag with long commit sha hash 219 | type=sha,format=long,prefix=commit- 220 | # Tag with git tag on release 221 | type=ref,event=tag 222 | type=raw,value=release,enable=${{ github.event_name == 'release' }} 223 | 224 | - name: Create manifest list and push 225 | working-directory: ${{ runner.temp }}/digests 226 | run: | 227 | # Process annotations 228 | declare -a ANNOTATIONS=() 229 | if [[ -n "$DOCKER_METADATA_OUTPUT_JSON" ]]; then 230 | while IFS= read -r annotation; do 231 | # Extract key and value by removing the manifest: prefix 232 | if [[ "$annotation" =~ ^manifest:(.+)=(.+)$ ]]; then 233 | key="${BASH_REMATCH[1]}" 234 | value="${BASH_REMATCH[2]}" 235 | # Use array to properly handle arguments with spaces 236 | ANNOTATIONS+=(--annotation "index:$key=$value") 237 | fi 238 | done < <(jq -r '.annotations[]' <<< "$DOCKER_METADATA_OUTPUT_JSON") 239 | fi 240 | 241 | TAGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ 242 | SOURCE_ARGS=$(printf "${GHCR_REPO}@sha256:%s " *) 243 | 244 | docker buildx imagetools create $TAGS "${ANNOTATIONS[@]}" $SOURCE_ARGS 245 | 246 | success-check-weather: 247 | name: Docker Build & Push Weather Success 248 | needs: [merge_weather, retag_weather] 249 | permissions: {} 250 | runs-on: ubuntu-latest 251 | if: always() 252 | steps: 253 | - name: Any jobs failed? 254 | if: ${{ contains(needs.*.result, 'failure') }} 255 | run: exit 1 256 | - name: All jobs passed or skipped 257 | if: ${{ !(contains(needs.*.result, 'failure')) }} 258 | run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}" -------------------------------------------------------------------------------- /src/routes/adjustmentMethods/EToAdjustmentMethod.ts: -------------------------------------------------------------------------------- 1 | import SunCalc from "suncalc"; 2 | import { AdjustmentMethod, AdjustmentMethodResponse, AdjustmentOptions } from "./AdjustmentMethod"; 3 | import { GeoCoordinates, PWS, WateringData } from "../../types"; 4 | import { WeatherProvider } from "../weatherProviders/WeatherProvider"; 5 | import { CodedError, ErrorCode } from "../../errors"; 6 | import { fromUnixTime, getDayOfYear, getUnixTime, isAfter, isBefore } from "date-fns"; 7 | 8 | 9 | /** 10 | * Calculates how much watering should be scaled based on weather and adjustment options by comparing the recent 11 | * potential ETo to the baseline potential ETo that the watering program was designed for. 12 | */ 13 | async function calculateEToWateringScale( 14 | adjustmentOptions: EToScalingAdjustmentOptions, 15 | coordinates: GeoCoordinates, 16 | weatherProvider: WeatherProvider, 17 | pws?: PWS 18 | ): Promise< AdjustmentMethodResponse > { 19 | 20 | // This will throw a CodedError if ETo data cannot be retrieved. 21 | const data = await weatherProvider.getWateringData( coordinates, pws ); 22 | const wateringData: readonly WateringData[] = data.value; 23 | 24 | let baseETo: number; 25 | // Default elevation is based on data from https://www.pnas.org/content/95/24/14009. 26 | let elevation = 600; 27 | 28 | if ( adjustmentOptions && "baseETo" in adjustmentOptions ) { 29 | baseETo = adjustmentOptions.baseETo 30 | } else { 31 | throw new CodedError( ErrorCode.MissingAdjustmentOption ); 32 | } 33 | 34 | if ( adjustmentOptions && "elevation" in adjustmentOptions ) { 35 | elevation = adjustmentOptions.elevation; 36 | } 37 | 38 | // Calculate ETo value for first day to return (precipitation is not a part of it) 39 | const returnETo = calculateETo( wateringData[0], elevation, coordinates); 40 | // Calculate eto scores per day 41 | const etos = wateringData.map(data => calculateETo( data, elevation, coordinates) - data.precip); 42 | 43 | // Compute uncapped scales for each score 44 | const uncappedScales = etos.map(score => score / baseETo * 100); 45 | 46 | // Compute a rolling average for each scale and cap them to 0-200 47 | let sum = 0; 48 | let count = 1; 49 | const scales = uncappedScales.map(scale => { 50 | sum += scale; 51 | const result = Math.floor( Math.min( Math.max( 0, sum / count), 200 ) ); 52 | count ++; 53 | return result; 54 | }); 55 | 56 | // Compute scale for most recent day for old firmware 57 | const scale = scales[0] 58 | return { 59 | scale: scale, 60 | rawData: { 61 | wp: wateringData[0].weatherProvider, 62 | eto: Math.round(returnETo * 1000) / 1000, 63 | radiation: Math.round( wateringData[0].solarRadiation * 100) / 100, 64 | minT: Math.round( wateringData[0].minTemp ), 65 | maxT: Math.round( wateringData[0].maxTemp ), 66 | minH: Math.round( wateringData[0].minHumidity ), 67 | maxH: Math.round( wateringData[0].maxHumidity ), 68 | wind: Math.round( wateringData[0].windSpeed * 10 ) / 10, 69 | p: Math.round( wateringData[0].precip * 100 ) / 100 70 | }, 71 | wateringData: wateringData, 72 | scales: scales, 73 | ttl: data.ttl, 74 | } 75 | } 76 | 77 | /* The implementation of this algorithm was guided by a step-by-step breakdown 78 | (http://edis.ifas.ufl.edu/pdffiles/ae/ae45900.pdf) */ 79 | /** 80 | * Calculates the reference potential evapotranspiration using the Penman-Monteith (FAO-56) method 81 | * (http://www.fao.org/3/X0490E/x0490e07.htm). 82 | * 83 | * @param wateringData The data to calculate the ETo with. 84 | * @param elevation The elevation above sea level of the watering site (in feet). 85 | * @param coordinates The coordinates of the watering site. 86 | * @return The reference potential evapotranspiration (in inches per day). 87 | */ 88 | export function calculateETo( wateringData: WateringData, elevation: number, coordinates: GeoCoordinates ): number { 89 | // Convert to Celsius. 90 | const minTemp = ( wateringData.minTemp - 32 ) * 5 / 9; 91 | const maxTemp = ( wateringData.maxTemp - 32 ) * 5 / 9; 92 | // Convert to meters. 93 | elevation = elevation / 3.281; 94 | // Convert to meters per second. 95 | const windSpeed = wateringData.windSpeed / 2.237; 96 | // Convert to megajoules. 97 | const solarRadiation = wateringData.solarRadiation * 3.6; 98 | 99 | const avgTemp = ( maxTemp + minTemp ) / 2; 100 | 101 | const saturationVaporPressureCurveSlope = 4098 * 0.6108 * Math.exp( 17.27 * avgTemp / ( avgTemp + 237.3 ) ) / Math.pow( avgTemp + 237.3, 2 ); 102 | 103 | const pressure = 101.3 * Math.pow( ( 293 - 0.0065 * elevation ) / 293, 5.26 ); 104 | 105 | const psychrometricConstant = 0.000665 * pressure; 106 | 107 | const deltaTerm = saturationVaporPressureCurveSlope / ( saturationVaporPressureCurveSlope + psychrometricConstant * ( 1 + 0.34 * windSpeed ) ); 108 | 109 | const psiTerm = psychrometricConstant / ( saturationVaporPressureCurveSlope + psychrometricConstant * ( 1 + 0.34 * windSpeed ) ); 110 | 111 | const tempTerm = ( 900 / ( avgTemp + 273 ) ) * windSpeed; 112 | 113 | const minSaturationVaporPressure = 0.6108 * Math.exp( 17.27 * minTemp / ( minTemp + 237.3 ) ); 114 | 115 | const maxSaturationVaporPressure = 0.6108 * Math.exp( 17.27 * maxTemp / ( maxTemp + 237.3 ) ); 116 | 117 | const avgSaturationVaporPressure = ( minSaturationVaporPressure + maxSaturationVaporPressure ) / 2; 118 | 119 | const actualVaporPressure = ( minSaturationVaporPressure * wateringData.maxHumidity / 100 + maxSaturationVaporPressure * wateringData.minHumidity / 100 ) / 2; 120 | 121 | const dayOfYear = getDayOfYear(fromUnixTime(wateringData.periodStartTime)); 122 | 123 | const inverseRelativeEarthSunDistance = 1 + 0.033 * Math.cos( 2 * Math.PI / 365 * dayOfYear ); 124 | 125 | const solarDeclination = 0.409 * Math.sin( 2 * Math.PI / 365 * dayOfYear - 1.39 ); 126 | 127 | const latitudeRads = Math.PI / 180 * coordinates[ 0 ]; 128 | 129 | const sunsetHourAngle = Math.acos( -Math.tan( latitudeRads ) * Math.tan( solarDeclination ) ); 130 | 131 | const extraterrestrialRadiation = 24 * 60 / Math.PI * 0.082 * inverseRelativeEarthSunDistance * ( sunsetHourAngle * Math.sin( latitudeRads ) * Math.sin( solarDeclination ) + Math.cos( latitudeRads ) * Math.cos( solarDeclination ) * Math.sin( sunsetHourAngle ) ); 132 | 133 | const clearSkyRadiation = ( 0.75 + 2e-5 * elevation ) * extraterrestrialRadiation; 134 | 135 | const netShortWaveRadiation = ( 1 - 0.23 ) * solarRadiation; 136 | 137 | const netOutgoingLongWaveRadiation = 4.903e-9 * ( Math.pow( maxTemp + 273.16, 4 ) + Math.pow( minTemp + 273.16, 4 ) ) / 2 * ( 0.34 - 0.14 * Math.sqrt( actualVaporPressure ) ) * ( 1.35 * solarRadiation / clearSkyRadiation - 0.35); 138 | 139 | const netRadiation = netShortWaveRadiation - netOutgoingLongWaveRadiation; 140 | 141 | const radiationTerm = deltaTerm * 0.408 * netRadiation; 142 | 143 | const windTerm = psiTerm * tempTerm * ( avgSaturationVaporPressure - actualVaporPressure ); 144 | 145 | return ( windTerm + radiationTerm ) / 25.4; 146 | } 147 | 148 | /** 149 | * Approximates the wind speed at 2 meters using the wind speed measured at another height. 150 | * @param speed The wind speed measured at the specified height (in miles per hour). 151 | * @param height The height of the measurement (in feet). 152 | * @returns The approximate wind speed at 2 meters (in miles per hour). 153 | */ 154 | export function standardizeWindSpeed( speed: number, height: number ) { 155 | return speed * 4.87 / Math.log( 67.8 * height / 3.281 - 5.42 ); 156 | } 157 | 158 | /* For hours where the Sun is too low to emit significant radiation, the formula for clear sky isolation will yield a 159 | * negative value. "radiationStart" marks the times of day when the Sun will rise high for solar isolation formula to 160 | * become positive, and "radiationEnd" marks the time of day when the Sun sets low enough that the equation will yield 161 | * a negative result. For any times outside of these ranges, the formula will yield incorrect results (they should be 162 | * clamped at 0 instead of being negative). 163 | */ 164 | SunCalc.addTime( Math.asin( 30 / 990 ) * 180 / Math.PI, "radiationStart", "radiationEnd" ); 165 | 166 | /** 167 | * Approximates total solar radiation for a day given cloud coverage information using a formula from 168 | * http://www.shodor.org/os411/courses/_master/tools/calculators/solarrad/ 169 | * @param cloudCoverInfo Information about the cloud coverage for several periods that span the entire day. 170 | * @param coordinates The coordinates of the location the data is from. 171 | * @return The total solar radiation for the day (in kilowatt hours per square meter per day). 172 | */ 173 | export function approximateSolarRadiation(cloudCoverInfo: CloudCoverInfo[], coordinates: GeoCoordinates ): number { 174 | return cloudCoverInfo.reduce( ( total, window: CloudCoverInfo ) => { 175 | const radiationStart: Date = SunCalc.getTimes( window.endTime, coordinates[0], coordinates[1])["radiationStart"]; 176 | const radiationEnd: Date = SunCalc.getTimes( window.startTime, coordinates[0], coordinates[1])["radiationEnd"]; 177 | 178 | // Clamp the start and end times of the window within time when the sun was emitting significant radiation. 179 | const startTime = isAfter(radiationStart, window.startTime) ? radiationStart : window.startTime; 180 | const endTime = isBefore(radiationEnd, window.endTime) ? radiationEnd: window.endTime; 181 | 182 | // The length of the window that will actually be used (in hours). 183 | const windowLength = (getUnixTime(endTime) - getUnixTime(startTime)) / (60 * 60); 184 | 185 | // Skip the window if there is no significant radiation during the time period. 186 | if ( windowLength <= 0 ) { 187 | return total; 188 | } 189 | 190 | const startPosition = SunCalc.getPosition( startTime, coordinates[ 0 ], coordinates[ 1 ] ); 191 | const endPosition = SunCalc.getPosition( endTime, coordinates[ 0 ], coordinates[ 1 ] ); 192 | const solarElevationAngle = ( startPosition.altitude + endPosition.altitude ) / 2; 193 | 194 | // Calculate radiation and convert from watts to kilowatts. 195 | const clearSkyIsolation = ( 990 * Math.sin( solarElevationAngle ) - 30 ) / 1000 * windowLength; 196 | 197 | return total + clearSkyIsolation * ( 1 - 0.75 * Math.pow( window.cloudCover, 3.4 ) ); 198 | }, 0 ); 199 | } 200 | 201 | export interface EToScalingAdjustmentOptions extends AdjustmentOptions { 202 | /** The watering site's height above sea level (in feet). */ 203 | elevation?: number; 204 | /** Baseline potential ETo (in inches per day). */ 205 | baseETo?: number; 206 | } 207 | 208 | /** Data about the cloud coverage for a period of time. */ 209 | export interface CloudCoverInfo { 210 | /** The start of this period of time. */ 211 | startTime: Date; 212 | /** The end of this period of time. */ 213 | endTime: Date; 214 | /** The average fraction of the sky covered by clouds during this time period. */ 215 | cloudCover: number; 216 | } 217 | 218 | const EToAdjustmentMethod: AdjustmentMethod = { 219 | calculateWateringScale: calculateEToWateringScale 220 | }; 221 | export default EToAdjustmentMethod; 222 | -------------------------------------------------------------------------------- /baselineEToData/dataPreparer.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | #define IMAGE_WIDTH 43200 7 | #define IMAGE_HEIGHT 16800 8 | #define MASK_WIDTH 10800 9 | #define MASK_HEIGHT 5400 10 | #define OUTPUT_FILE_TEMPLATE "./Baseline_ETo_Data-Pass_%d.bin" 11 | #define FILENAME_MAX_LENGTH 40 12 | #define HEADER_SIZE 32 13 | 14 | long unsigned CROPPED_TOP_PIXELS = (MASK_WIDTH * MASK_HEIGHT * 10 / 180); 15 | // These will be set by findPixelRange(). 16 | uint16_t minPixelValue = 0; 17 | uint16_t maxPixelValue = 0xFFFF; 18 | double bitReductionFactor = 256; 19 | 20 | 21 | /** Copies the big-endian byte representation of the specified value into the specified buffer. */ 22 | void copyBytes(void* input, uint8_t* output, int unsigned length) { 23 | int unsigned isBigEndian = 1; 24 | isBigEndian = *((uint8_t*)(&isBigEndian)) == 0; 25 | 26 | for (int unsigned i = 0; i < length; i++) { 27 | int unsigned index = isBigEndian ? i : length - i - 1; 28 | output[i] = *((uint8_t*) input + index); 29 | } 30 | } 31 | 32 | /** 33 | * Write file header to the specified buffer. The header format is documented in the README. 34 | */ 35 | void setHeader(uint8_t *header) { 36 | for (int unsigned i = 0; i < HEADER_SIZE; i++) { 37 | header[i] = 0; 38 | } 39 | 40 | uint32_t width = IMAGE_WIDTH; 41 | uint32_t height = IMAGE_HEIGHT; 42 | // originally 0.1, then multiplied by a value to compensate for the bit depth reduction and divided by 25.4 to convert to inches. 43 | float scalingFactor = 0.1 * bitReductionFactor / 25.4; 44 | float minimumETo = minPixelValue * 0.1 / 25.4; 45 | 46 | // Version 47 | header[0] = 1; 48 | // Width 49 | copyBytes(&width, &(header[1]), 4); 50 | // Height 51 | copyBytes(&height, &(header[5]), 4); 52 | // Bit depth 53 | header[9] = 8; 54 | // Minimum ETo 55 | copyBytes(&minimumETo, &(header[10]), 4); 56 | // Scaling factor 57 | copyBytes(&scalingFactor, &(header[14]), 4); 58 | } 59 | 60 | /** 61 | * Calculates the minimum and maximum pixel values used in the image. These values can be used to optimally reduce the 62 | * bit depth by mapping the minimum value to 0 and the maximum value to 254 (reserving 255 for fill pixels) and linearly 63 | * interpolating the rest of the values. 64 | */ 65 | void findPixelRange(uint16_t* minPtr, uint16_t* maxPtr, double* bitReductionFactorPtr) { 66 | time_t startTime = clock(); 67 | 68 | uint16_t minValue = 0xFFFF; 69 | uint16_t maxValue = 0; 70 | 71 | FILE* inputFile = fopen("./MOD16A3_PET_2000_to_2013_mean.bin", "rb"); 72 | if (inputFile == NULL) { 73 | printf("An error occurred opening image file while finding min/max value.\n"); 74 | exit(1); 75 | } 76 | uint16_t buffer[IMAGE_WIDTH]; 77 | for (int unsigned y = 0; y < IMAGE_HEIGHT; y++) { 78 | if (y % 1000 == 0) { 79 | printf("Finding pixel range on row %d...\n", y); 80 | } 81 | 82 | fread(buffer, 2, IMAGE_WIDTH, inputFile); 83 | if (ferror(inputFile)) { 84 | printf("An error occurred reading image row %d while finding min/max values.\n", y); 85 | exit(1); 86 | } 87 | if (feof(inputFile)) { 88 | printf("Encountered EOF reading image row %d while finding min/max values.\n", y); 89 | exit(1); 90 | } 91 | 92 | for (unsigned int x = 0; x < IMAGE_WIDTH; x++) { 93 | uint16_t pixel = buffer[x]; 94 | // Skip fill pixels. 95 | if (pixel > 65528) { 96 | continue; 97 | } 98 | 99 | minValue = pixel < minValue ? pixel : minValue; 100 | maxValue = pixel > maxValue ? pixel : maxValue; 101 | } 102 | } 103 | 104 | *minPtr = minValue; 105 | *maxPtr = maxValue; 106 | *bitReductionFactorPtr = (maxValue - minValue + 1) / (float) 256; 107 | 108 | fclose(inputFile); 109 | printf("Found pixel range in %.1f seconds. Min value: %d\t Max value: %d\t Bit reduction factor:%f.\n", (clock() - startTime) / (float) CLOCKS_PER_SEC, minValue, maxValue, *bitReductionFactorPtr); 110 | } 111 | 112 | /** Reduces the image bit depth from 16 bits to 8 bits. */ 113 | void reduceBitDepth() { 114 | clock_t startTime = clock(); 115 | FILE* originalFile = fopen("./MOD16A3_PET_2000_to_2013_mean.bin", "rb"); 116 | if (originalFile == NULL) { 117 | printf("An error occurred opening input image file while reducing bit depth.\n"); 118 | exit(1); 119 | } 120 | 121 | char* reducedFileName = malloc(FILENAME_MAX_LENGTH); 122 | snprintf(reducedFileName, FILENAME_MAX_LENGTH, OUTPUT_FILE_TEMPLATE, 0); 123 | FILE* reducedFile = fopen(reducedFileName, "wb"); 124 | if (reducedFile == NULL) { 125 | printf("An error occurred opening output image file while reducing bit depth.\n"); 126 | exit(1); 127 | } 128 | 129 | // Write the file header. 130 | uint8_t header[32]; 131 | setHeader(header); 132 | fwrite(header, 1, 32, reducedFile); 133 | if (ferror(reducedFile)) { 134 | printf("An error occurred writing file header while reducing bit depth.\n"); 135 | exit(1); 136 | } 137 | 138 | uint16_t inputBuffer[IMAGE_WIDTH]; 139 | uint8_t outputBuffer[IMAGE_WIDTH]; 140 | for (int unsigned y = 0; y < IMAGE_HEIGHT; y++) { 141 | if (y % 1000 == 0) { 142 | printf("Reducing bit depth on row %d...\n", y); 143 | } 144 | 145 | fread(inputBuffer, 2, IMAGE_WIDTH, originalFile); 146 | if (ferror(originalFile)) { 147 | printf("An error occurred reading row %d while reducing bit depth.\n", y); 148 | exit(1); 149 | } 150 | if (feof(originalFile)) { 151 | printf("Encountered EOF reading row %d while reducing bit depth.\n", y); 152 | exit(1); 153 | } 154 | 155 | for (unsigned int x = 0; x < IMAGE_WIDTH; x++) { 156 | uint16_t originalPixel = inputBuffer[x]; 157 | uint8_t reducedPixel = originalPixel > 65528 ? 255 : (uint8_t) ((originalPixel - minPixelValue) / bitReductionFactor); 158 | outputBuffer[x] = reducedPixel; 159 | } 160 | 161 | fwrite(outputBuffer, 1, IMAGE_WIDTH, reducedFile); 162 | if (ferror(reducedFile)) { 163 | printf("An error occurred writing row %d while reducing bit depth.\n", y); 164 | exit(1); 165 | } 166 | } 167 | 168 | fclose(reducedFile); 169 | fclose(originalFile); 170 | 171 | printf("Finished reducing bit depth in %.1f seconds.\n", (clock() - startTime) / (double) CLOCKS_PER_SEC); 172 | } 173 | 174 | void fillMissingPixels(int unsigned pass) { 175 | clock_t startTime = clock(); 176 | 177 | char* inputFileName = malloc(FILENAME_MAX_LENGTH); 178 | snprintf(inputFileName, FILENAME_MAX_LENGTH, OUTPUT_FILE_TEMPLATE, pass - 1); 179 | FILE* inputFile = fopen(inputFileName, "rb"); 180 | if (inputFile == NULL) { 181 | printf("An error occurred opening input image file on pass %d.\n", pass); 182 | exit(1); 183 | } 184 | 185 | char* outputFileName = malloc(FILENAME_MAX_LENGTH); 186 | snprintf(outputFileName, FILENAME_MAX_LENGTH, OUTPUT_FILE_TEMPLATE, pass); 187 | FILE* outputFile = fopen(outputFileName, "wb"); 188 | if (outputFile == NULL) { 189 | printf("An error occurred opening output image file on pass %d.\n", pass); 190 | exit(1); 191 | } 192 | 193 | FILE* maskFile = fopen("./Ocean_Mask.bin", "rb"); 194 | if (maskFile == NULL) { 195 | printf("An error occurred opening mask image on pass %d.\n", pass); 196 | exit(1); 197 | } 198 | 199 | uint8_t outputBuffer[IMAGE_WIDTH]; 200 | 201 | // Skip the header. 202 | fseek(inputFile, 32, SEEK_SET); 203 | if (ferror(inputFile)) { 204 | printf("An error occurred reading header on pass %d.\n", pass); 205 | exit(1); 206 | } 207 | if (feof(inputFile)) { 208 | printf("Encountered EOF reading header on pass %d.\n", pass); 209 | exit(1); 210 | } 211 | 212 | // Write the file header. 213 | uint8_t header[32]; 214 | setHeader(header); 215 | fwrite(header, 1, 32, outputFile); 216 | if (ferror(outputFile)) { 217 | printf("An error occurred writing file header on pass %d.\n", pass); 218 | exit(1); 219 | } 220 | 221 | uint8_t* rows[5] = {0, 0, 0, 0, 0}; 222 | // Read the first 2 rows. 223 | for (int unsigned rowIndex = 3; rowIndex < 5; rowIndex++) { 224 | uint8_t* row = (uint8_t*) malloc(IMAGE_WIDTH); 225 | fread(row, 1, IMAGE_WIDTH, inputFile); 226 | if (ferror(inputFile)) { 227 | printf("An error occurred reading image row %d on pass %d.\n", rowIndex - 3, pass); 228 | exit(1); 229 | } 230 | if (feof(inputFile)) { 231 | printf("Encountered EOF reading image row %d on pass %d.\n", rowIndex - 3, pass); 232 | exit(1); 233 | } 234 | 235 | rows[rowIndex] = row; 236 | } 237 | 238 | long unsigned fixedPixels = 0; 239 | long unsigned unfixablePixels = 0; 240 | long unsigned waterPixels = 0; 241 | 242 | for (int unsigned y = 0; y < IMAGE_HEIGHT; y++) { 243 | if (y % 1000 == 0) { 244 | printf("Filling missing pixels on pass %d row %d.\n", pass, y); 245 | } 246 | 247 | // Read a row from the mask. 248 | uint8_t maskRow[MASK_WIDTH]; 249 | int unsigned maskOffset = y / (IMAGE_WIDTH / MASK_WIDTH) * MASK_WIDTH + CROPPED_TOP_PIXELS; 250 | fseek(maskFile, maskOffset, SEEK_SET); 251 | fread(maskRow, 1, MASK_WIDTH, maskFile); 252 | if (ferror(maskFile)) { 253 | printf("An error occurred reading mask at offset %d on pass %d.\n", maskOffset, pass); 254 | exit(1); 255 | } 256 | if (feof(maskFile)) { 257 | printf("Encountered EOF reading mask at offset %d on pass %d.\n", maskOffset, pass); 258 | exit(1); 259 | } 260 | 261 | // Free the oldest row. 262 | free(rows[0]); 263 | // Shift the previous rows back. 264 | for (int unsigned rowIndex = 1; rowIndex < 5; rowIndex++) { 265 | rows[rowIndex - 1] = rows[rowIndex]; 266 | } 267 | 268 | // Read the next row if one exists. 269 | if (y < IMAGE_HEIGHT - 2) { 270 | uint8_t* row = malloc(IMAGE_WIDTH); 271 | fread(row, 1, IMAGE_WIDTH, inputFile); 272 | if (ferror(inputFile)) { 273 | printf("An error occurred reading image row %d on pass %d.\n", y + 2, pass); 274 | exit(1); 275 | } 276 | if (feof(inputFile)) { 277 | printf("Encountered EOF reading image row %d on pass %d,\n", y + 2, pass); 278 | exit(1); 279 | } 280 | 281 | rows[4] = row; 282 | } 283 | 284 | for (unsigned int x = 0; x < IMAGE_WIDTH; x++) { 285 | uint8_t pixel = *(rows[2] +x); 286 | // Skip water pixels. 287 | if (maskRow[x / (IMAGE_WIDTH / MASK_WIDTH)] > 128) { 288 | if (pixel == 255) { 289 | int unsigned totalWeight = 0; 290 | float neighborTotal = 0; 291 | for (int i = -2; i <= 2; i++) { 292 | for (int j = -2; j <= 2; j++) { 293 | int neighborX = x + i; 294 | int neighborY = y + j; 295 | if (neighborX < 0 || neighborX >= IMAGE_WIDTH || neighborY < 0 || neighborY >= IMAGE_HEIGHT) { 296 | continue; 297 | } 298 | 299 | uint8_t neighbor = *(rows[2 + j] + neighborX); 300 | if (neighbor == 255) { 301 | continue; 302 | } 303 | 304 | int unsigned weight = 5 - (abs(i) + abs(j)); 305 | neighborTotal += weight * neighbor; 306 | totalWeight += weight; 307 | } 308 | } 309 | if (totalWeight > 11) { 310 | pixel = (uint8_t) (neighborTotal / totalWeight); 311 | fixedPixels++; 312 | } else { 313 | unfixablePixels++; 314 | } 315 | } 316 | } else { 317 | waterPixels++; 318 | } 319 | 320 | outputBuffer[x] = pixel; 321 | } 322 | 323 | fwrite(outputBuffer, 1, IMAGE_WIDTH, outputFile); 324 | if (ferror(outputFile)) { 325 | printf("An error occurred writing row %d on pass %d.\n", y, pass); 326 | exit(1); 327 | } 328 | } 329 | 330 | fclose(outputFile); 331 | fclose(inputFile); 332 | fclose(maskFile); 333 | 334 | printf("Finished pass %d in %f seconds. Fixed pixels: %ld\t Unfixable pixels: %ld\t Water pixels: %ld.\n", pass, (clock() - startTime) / (double) CLOCKS_PER_SEC, fixedPixels, unfixablePixels, waterPixels); 335 | } 336 | 337 | int main(int argc, char* argv[]) { 338 | if (argc != 2) { 339 | printf("Proper usage: %s \n", argv[0]); 340 | } 341 | int unsigned passes = strtol(argv[1], NULL, 10); 342 | if (passes <= 0) { 343 | printf("passes argument must be a positive integer.\n"); 344 | exit(1); 345 | } 346 | 347 | findPixelRange(&minPixelValue, &maxPixelValue, &bitReductionFactor); 348 | reduceBitDepth(); 349 | for (int unsigned i = 1; i <= passes; i++) { 350 | fillMissingPixels(i); 351 | } 352 | 353 | return 0; 354 | } 355 | -------------------------------------------------------------------------------- /src/routes/weatherProviders/Apple.ts: -------------------------------------------------------------------------------- 1 | import { importPKCS8, SignJWT } from "jose"; 2 | 3 | import { GeoCoordinates, WeatherData, WateringData, PWS } from "../../types"; 4 | import { getTZ, httpJSONRequest, localTime } from "../weather"; 5 | import { WeatherProvider } from "./WeatherProvider"; 6 | import { 7 | approximateSolarRadiation, 8 | CloudCoverInfo, 9 | } from "../adjustmentMethods/EToAdjustmentMethod"; 10 | import { CodedError, ErrorCode } from "../../errors"; 11 | import { format, addHours, getUnixTime, startOfDay, subDays } from "date-fns"; 12 | import { TZDate } from "@date-fns/tz"; 13 | 14 | type UnitsSystem = "m"; 15 | type MoonPhase = "new" | "waxingCrescent" | "firstQuarter" | "waxingGibbous" | "full" | "waningGibbous" | "thirdQuarter" | "waningCrescent"; 16 | type PrecipitationType = "clear" | "precipitation" | "rain" | "snow" | "sleet" | "hail" | "mixed"; 17 | type PressureTrend = "rising" | "falling" | "steady"; 18 | type Certainty = "observed" | "likely" | "possible" | "unlikely" | "unknown"; 19 | type AlertResponseType = "shelter" | "evacuate" | "prepare" | "execute" | "avoid" | "monitor" | "assess" | "allClear" | "none"; 20 | type Severity = "extreme" | "severe" | "moderate" | "minor" | "unknown"; 21 | type Urgency = "immediate" | "expected" | "future" | "past" | "unknown"; 22 | 23 | interface Metadata { 24 | attributionURL?: string; // URL of the legal attribution for the data source 25 | expireTime: string; // ISO 8601 date-time; required 26 | language?: string; // ISO language code 27 | latitude: number; // Required 28 | longitude: number; // Required 29 | providerLogo?: string; // URL for provider logo 30 | providerName?: string; // Name of the data provider 31 | readTime: string; // ISO 8601 date-time; required 32 | reportedTime?: string; // ISO 8601 date-time 33 | temporarilyUnavailable?: boolean; // True if provider data is temporarily unavailable 34 | units?: UnitsSystem; // Units system (e.g., metric) 35 | version: number; // Required; format version 36 | } 37 | 38 | interface CurrentWeather { 39 | name: string, 40 | metadata: Metadata, 41 | asOf: string; // Required; ISO 8601 date-time 42 | cloudCover?: number; // Optional; 0 to 1 43 | conditionCode: string; // Required; enumeration of weather condition 44 | daylight?: boolean; // Optional; indicates daylight 45 | humidity: number; // Required; 0 to 1 46 | precipitationIntensity: number; // Required; in mm/h 47 | pressure: number; // Required; in millibars 48 | pressureTrend: PressureTrend; // Required; direction of pressure change 49 | temperature: number; // Required; in °C 50 | temperatureApparent: number; // Required; feels-like temperature in °C 51 | temperatureDewPoint: number; // Required; in °C 52 | uvIndex: number; // Required; UV radiation level 53 | visibility: number; // Required; in meters 54 | windDirection?: number; // Optional; in degrees 55 | windGust?: number; // Optional; max wind gust speed in km/h 56 | windSpeed: number; // Required; in km/h 57 | } 58 | 59 | interface DayPartForecast { 60 | cloudCover: number; // Required; 0 to 1 61 | conditionCode: string; // Required; enumeration of weather condition 62 | forecastEnd: string; // Required; ISO 8601 date-time 63 | forecastStart: string; // Required; ISO 8601 date-time 64 | humidity: number; // Required; 0 to 1 65 | precipitationAmount: number; // Required; in millimeters 66 | precipitationChance: number; // Required; as a percentage 67 | precipitationType: PrecipitationType; // Required 68 | snowfallAmount: number; // Required; in millimeters 69 | windDirection?: number; // Optional; in degrees 70 | windSpeed: number; // Required; in km/h 71 | } 72 | 73 | interface DailyForecastData { 74 | conditionCode: string; // Required; condition at the time 75 | daytimeForecast?: DayPartForecast; // Forecast between 7 AM and 7 PM 76 | forecastEnd: string; // Required; ISO 8601 date-time 77 | forecastStart: string; // Required; ISO 8601 date-time 78 | maxUvIndex: number; // Required; maximum UV index 79 | moonPhase: MoonPhase; // Required; phase of the moon 80 | moonrise?: string; // ISO 8601 date-time 81 | moonset?: string; // ISO 8601 date-time 82 | overnightForecast?: DayPartForecast; // Forecast between 7 PM and 7 AM 83 | precipitationAmount: number; // Required; in millimeters 84 | precipitationChance: number; // Required; as a percentage 85 | precipitationType: PrecipitationType; // Required 86 | snowfallAmount: number; // Required; in millimeters 87 | solarMidnight?: string; // ISO 8601 date-time 88 | solarNoon?: string; // ISO 8601 date-time 89 | sunrise?: string; // ISO 8601 date-time 90 | sunriseAstronomical?: string; // ISO 8601 date-time 91 | sunriseCivil?: string; // ISO 8601 date-time 92 | sunriseNautical?: string; // ISO 8601 date-time 93 | sunset?: string; // ISO 8601 date-time 94 | sunsetAstronomical?: string; // ISO 8601 date-time 95 | sunsetCivil?: string; // ISO 8601 date-time 96 | sunsetNautical?: string; // ISO 8601 date-time 97 | temperatureMax: number; // Required; in °C 98 | temperatureMin: number; // Required; in °C 99 | } 100 | 101 | interface DailyForecast { 102 | name: string, 103 | metadata: Metadata, 104 | days: DailyForecastData[]; 105 | } 106 | 107 | interface HourWeatherConditions { 108 | cloudCover: number; // Required; 0 to 1 109 | conditionCode: string; // Required; enumeration of weather condition 110 | daylight?: boolean; // Indicates whether the hour is during day or night 111 | forecastStart: string; // Required; ISO 8601 date-time 112 | humidity: number; // Required; 0 to 1 113 | precipitationChance: number; // Required; 0 to 1 114 | precipitationType: PrecipitationType; // Required 115 | pressure: number; // Required; in millibars 116 | pressureTrend?: PressureTrend; // Optional; direction of pressure change 117 | snowfallIntensity?: number; // Optional; in mm/h 118 | temperature: number; // Required; in °C 119 | temperatureApparent: number; // Required; feels-like temperature in °C 120 | temperatureDewPoint?: number; // Optional; in °C 121 | uvIndex: number; // Required; UV radiation level 122 | visibility: number; // Required; in meters 123 | windDirection?: number; // Optional; in degrees 124 | windGust?: number; // Optional; max wind gust speed in km/h 125 | windSpeed: number; // Required; in km/h 126 | precipitationAmount?: number; // Optional; in mm 127 | } 128 | 129 | interface HourlyForecast { 130 | name: string, 131 | metadata: Metadata, 132 | hours: HourWeatherConditions[]; 133 | } 134 | 135 | interface ForecastMinute { 136 | precipitationChance: number; // Required; probability of precipitation (0 to 1) 137 | precipitationIntensity: number; // Required; intensity in mm/h 138 | startTime: string; // Required; ISO 8601 date-time 139 | } 140 | 141 | interface ForecastPeriodSummary { 142 | condition: PrecipitationType; // Required; type of precipitation 143 | endTime?: string; // Optional; ISO 8601 date-time 144 | precipitationChance: number; // Required; probability of precipitation (0 to 1) 145 | precipitationIntensity: number; // Required; intensity in mm/h 146 | startTime: string; // Required; ISO 8601 date-time 147 | } 148 | 149 | interface NextHourForecast { 150 | name: string, 151 | metadata: Metadata, 152 | forecastEnd?: string; // ISO 8601 date-time 153 | forecastStart?: string; // ISO 8601 date-time 154 | minutes: ForecastMinute[]; // Required; array of forecast minutes 155 | summary: ForecastPeriodSummary[]; // Required; array of forecast summaries 156 | } 157 | 158 | interface WeatherAlertSummary { 159 | areaId?: string; // Official designation of the affected area 160 | areaName?: string; // Human-readable name of the affected area 161 | certainty: Certainty; // Required; likelihood of the event 162 | countryCode: string; // Required; ISO country code 163 | description: string; // Required; human-readable description 164 | detailsUrl?: string; // URL to detailed information 165 | effectiveTime: string; // Required; ISO 8601 date-time 166 | eventEndTime?: string; // ISO 8601 date-time 167 | eventOnsetTime?: string; // ISO 8601 date-time 168 | expireTime: string; // Required; ISO 8601 date-time 169 | id: string; // Required; UUID 170 | issuedTime: string; // Required; ISO 8601 date-time 171 | responses: AlertResponseType[]; // Required; recommended actions 172 | severity: Severity; // Required; danger level 173 | source: string; // Required; reporting agency 174 | urgency?: Urgency; // Optional; urgency of action 175 | } 176 | 177 | interface WeatherAlertCollection { 178 | name: string, 179 | metadata: Metadata, 180 | alerts: WeatherAlertSummary[]; 181 | } 182 | 183 | interface AppleWeather { 184 | currentWeather: CurrentWeather; // The current weather for the requested location. 185 | forecastDaily: DailyForecast; // The daily forecast for the requested location. 186 | forecastHourly: HourlyForecast; // The hourly forecast for the requested location. 187 | forecastNextHour: NextHourForecast; // The next hour forecast for the requested location. 188 | weatherAlerts: WeatherAlertCollection; // Weather alerts for the requested location. 189 | } 190 | 191 | export default class AppleWeatherProvider extends WeatherProvider { 192 | private readonly API_KEY: Promise; 193 | 194 | public constructor() { 195 | super(); 196 | 197 | if (!process.env.APPLE_PRIVATE_KEY) { 198 | return; 199 | } 200 | 201 | this.API_KEY = this.getKey(); 202 | } 203 | 204 | private async getKey(): Promise { 205 | const privateKey = await importPKCS8( 206 | process.env.APPLE_PRIVATE_KEY, 207 | "ES256" 208 | ); 209 | 210 | return await new SignJWT({ sub: process.env.APPLE_SERVICE_ID }) 211 | .setProtectedHeader({ 212 | alg: "ES256", 213 | kid: process.env.APPLE_KEY_ID, 214 | id: `${process.env.APPLE_TEAM_ID}.${process.env.APPLE_SERVICE_ID}`, // custom header field 215 | }) 216 | .setJti(`${process.env.APPLE_TEAM_ID}.${process.env.APPLE_SERVICE_ID}`) 217 | .setIssuer(process.env.APPLE_TEAM_ID) 218 | .setExpirationTime("10y") 219 | .sign(privateKey); 220 | } 221 | 222 | protected async getWateringDataInternal( 223 | coordinates: GeoCoordinates, 224 | pws: PWS | undefined 225 | ): Promise { 226 | const currentDay = startOfDay(localTime(coordinates)); 227 | 228 | const tz = getTZ(coordinates); 229 | 230 | const startTimestamp = new Date(+subDays(currentDay, 10)).toISOString(); 231 | const endTimestamp = new Date(+currentDay).toISOString(); 232 | 233 | const historicUrl = `https://weatherkit.apple.com/api/v1/weather/en/${ 234 | coordinates[0] 235 | }/${ 236 | coordinates[1] 237 | }?dataSets=forecastHourly,forecastDaily¤tAsOf=${endTimestamp}&hourlyStart=${startTimestamp}&hourlyEnd=${endTimestamp}&dailyStart=${startTimestamp}&dailyEnd=${endTimestamp}&timezone=${tz}`; 238 | 239 | let historicData: AppleWeather; 240 | try { 241 | historicData = await httpJSONRequest(historicUrl, { 242 | Authorization: `Bearer ${await this.API_KEY}`, 243 | }); 244 | } catch (err) { 245 | console.error("Error retrieving weather information from Apple:", err); 246 | throw new CodedError(ErrorCode.WeatherApiError); 247 | } 248 | 249 | if (!historicData.forecastHourly || !historicData.forecastHourly.hours) { 250 | throw new CodedError(ErrorCode.MissingWeatherField); 251 | } 252 | 253 | const hours = historicData.forecastHourly.hours; 254 | const days = historicData.forecastDaily.days; 255 | 256 | // Fail if not enough data is available. 257 | // There will only be 23 samples on the day that daylight saving time begins. 258 | if (hours.length < 23) { 259 | throw new CodedError(ErrorCode.InsufficientWeatherData); 260 | } 261 | 262 | // Cut hours down into full 24 hour section 263 | hours.splice(0, hours.length % 24); 264 | const daysInHours: HourWeatherConditions[][] = []; 265 | for (let i = 0; i < hours.length; i += 24) { 266 | daysInHours.push(hours.slice(i, i + 24)); 267 | } 268 | 269 | // Cut days down to match number of hours 270 | days.splice(0, days.length - daysInHours.length); 271 | daysInHours.splice(0, daysInHours.length - days.length); 272 | 273 | // Pull data for each day of the given interval 274 | const data = []; 275 | for (let i = 0; i < daysInHours.length; i++) { 276 | let temp: number = 0, 277 | humidity: number = 0, 278 | minHumidity: number = undefined, 279 | maxHumidity: number = undefined; 280 | 281 | const cloudCoverInfo: CloudCoverInfo[] = daysInHours[i].map( 282 | (hour): CloudCoverInfo => { 283 | const startTime = new TZDate(hour.forecastStart, tz); 284 | 285 | return { 286 | startTime, 287 | endTime: addHours(startTime, 1), 288 | cloudCover: hour.cloudCover, 289 | }; 290 | } 291 | ); 292 | 293 | for (const hour of daysInHours[i]) { 294 | /* 295 | * If temperature or humidity is missing from a sample, the total will become NaN. This is intended since 296 | * calculateWateringScale will treat NaN as a missing value and temperature/humidity can't be accurately 297 | * calculated when data is missing from some samples (since they follow diurnal cycles and will be 298 | * significantly skewed if data is missing for several consecutive hours). 299 | */ 300 | temp += this.celsiusToFahrenheit(hour.temperature); 301 | humidity += hour.humidity; 302 | 303 | // ETo should skip NaN humidity 304 | if (hour.humidity === undefined) { 305 | continue; 306 | } 307 | 308 | // If minHumidity or maxHumidity is undefined, these comparisons will yield false. 309 | minHumidity = minHumidity < hour.humidity ? minHumidity : hour.humidity; 310 | maxHumidity = maxHumidity > hour.humidity ? maxHumidity : hour.humidity; 311 | } 312 | 313 | const length = daysInHours[i].length; 314 | const windSpeed = 315 | (days[i].daytimeForecast?.windSpeed || 0 + 316 | days[i].overnightForecast.windSpeed) / 317 | 2; 318 | 319 | data.push({ 320 | weatherProvider: "Apple", 321 | temp: temp / length, 322 | humidity: (humidity / length) * 100, 323 | periodStartTime: getUnixTime(new Date( 324 | historicData.forecastDaily.days[i].forecastStart 325 | )), 326 | minTemp: this.celsiusToFahrenheit( 327 | historicData.forecastDaily.days[i].temperatureMin 328 | ), 329 | maxTemp: this.celsiusToFahrenheit( 330 | historicData.forecastDaily.days[i].temperatureMax 331 | ), 332 | minHumidity: minHumidity * 100, 333 | maxHumidity: maxHumidity * 100, 334 | solarRadiation: approximateSolarRadiation(cloudCoverInfo, coordinates), 335 | // Assume wind speed measurements are taken at 2 meters. 336 | windSpeed: this.kphToMph(windSpeed), 337 | precip: this.mmToInchesPerHour( 338 | historicData.forecastDaily.days[i].precipitationAmount || 0 339 | ), 340 | }); 341 | } 342 | 343 | return data.reverse(); 344 | } 345 | 346 | protected async getWeatherDataInternal( 347 | coordinates: GeoCoordinates, 348 | pws: PWS | undefined 349 | ): Promise { 350 | const tz = getTZ(coordinates); 351 | 352 | const forecastUrl = `https://weatherkit.apple.com/api/v1/weather/en/${coordinates[0]}/${coordinates[1]}?dataSets=currentWeather,forecastDaily&timezone=${tz}`; 353 | 354 | let forecast: AppleWeather; 355 | try { 356 | forecast = await httpJSONRequest(forecastUrl, { 357 | Authorization: `Bearer ${await this.API_KEY}`, 358 | }); 359 | } catch (err) { 360 | console.error("Error retrieving weather information from Apple:", err); 361 | throw "An error occurred while retrieving weather information from Apple."; 362 | } 363 | 364 | if ( 365 | !forecast.currentWeather || 366 | !forecast.forecastDaily || 367 | !forecast.forecastDaily.days 368 | ) { 369 | throw "Necessary field(s) were missing from weather information returned by Apple."; 370 | } 371 | 372 | const weather: WeatherData = { 373 | weatherProvider: "Apple", 374 | temp: Math.floor( 375 | this.celsiusToFahrenheit(forecast.currentWeather.temperature) 376 | ), 377 | humidity: Math.floor(forecast.currentWeather.humidity * 100), 378 | wind: Math.floor(this.kphToMph(forecast.currentWeather.windSpeed)), 379 | raining: forecast.currentWeather.precipitationIntensity > 0, 380 | description: forecast.currentWeather.conditionCode, 381 | icon: this.getOWMIconCode(forecast.currentWeather.conditionCode), 382 | 383 | region: "", 384 | city: "", 385 | minTemp: Math.floor( 386 | this.celsiusToFahrenheit(forecast.forecastDaily.days[0].temperatureMin) 387 | ), 388 | maxTemp: Math.floor( 389 | this.celsiusToFahrenheit(forecast.forecastDaily.days[0].temperatureMax) 390 | ), 391 | precip: this.mmToInchesPerHour( 392 | forecast.forecastDaily.days[0].precipitationAmount 393 | ), 394 | forecast: [], 395 | }; 396 | 397 | for (let index = 0; index < forecast.forecastDaily.days.length; index++) { 398 | weather.forecast.push({ 399 | temp_min: Math.floor( 400 | this.celsiusToFahrenheit( 401 | forecast.forecastDaily.days[index].temperatureMin 402 | ) 403 | ), 404 | temp_max: Math.floor( 405 | this.celsiusToFahrenheit( 406 | forecast.forecastDaily.days[index].temperatureMax 407 | ) 408 | ), 409 | precip: this.mmToInchesPerHour( 410 | forecast.forecastDaily.days[index].precipitationAmount 411 | ), 412 | date: getUnixTime(new TZDate(forecast.forecastDaily.days[index].forecastStart, tz)), 413 | icon: this.getOWMIconCode( 414 | forecast.forecastDaily.days[index].conditionCode 415 | ), 416 | description: forecast.forecastDaily.days[index].conditionCode, 417 | }); 418 | } 419 | 420 | return weather; 421 | } 422 | 423 | public shouldCacheWateringScale(): boolean { 424 | return true; 425 | } 426 | 427 | private getOWMIconCode(icon: string) { 428 | switch (icon.toLowerCase()) { 429 | case "mostlyclear": 430 | case "partlycloudy": 431 | return "02n"; 432 | case "mostlycloudy": 433 | case "cloudy": 434 | case "smokey": 435 | return "03d"; 436 | case "foggy": 437 | case "haze": 438 | case "windy": 439 | case "breezy": 440 | return "50d"; 441 | case "sleet": 442 | case "snow": 443 | case "frigid": 444 | case "hail": 445 | case "flurries": 446 | case "sunflurries": 447 | case "wintrymix": 448 | case "blizzard": 449 | case "blowingsnow": 450 | case "freezingdrizzle": 451 | case "freezingrain": 452 | case "heavysnow": 453 | return "13d"; 454 | case "rain": 455 | case "drizzle": 456 | case "heavyrain": 457 | case "isolatedthunderstorms": 458 | case "sunshowers": 459 | case "scatteredthunderstorms": 460 | case "strongstorms": 461 | case "thunderstorms": 462 | return "10d"; 463 | case "clear": 464 | default: 465 | return "01d"; 466 | } 467 | } 468 | 469 | private celsiusToFahrenheit(celsius) { 470 | return (celsius * 9) / 5 + 32; 471 | } 472 | 473 | private mmToInchesPerHour(mmPerHour) { 474 | return mmPerHour * 0.03937007874; 475 | } 476 | 477 | private kphToMph(kph) { 478 | return kph * 0.621371; 479 | } 480 | } 481 | --------------------------------------------------------------------------------