Commit: 1cf0824faf225a446bdf7447b36d08fabd142c86 Author: Vi Grey Date: 2023-08-05 00:49 UTC Summary: Initial commit .gitignore | 1 + CHANGELOG.md | 10 ++++ LICENSE | 24 ++++++++ Makefile | 12 ++++ README.txt | 37 ++++++++++++ go.mod | 3 + src/conversions.go | 108 +++++++++++++++++++++++++++++++++++ src/weather-nws.go | 241 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 436 insertions(+) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6ea27a5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Change Log +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) + +## [0.0.0] - 2023-08-04 + +### Added + +- Initial release diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..91c0826 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +Copyright (C) 2023, Vi Grey +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY AUTHOR AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL AUTHOR OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5c99ede --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +PKG_NAME := weather-nws +CURRENTDIR := $(dir $(realpath $(firstword $(MAKEFILE_LIST)))) + +all: + mkdir -p $(CURRENTDIR)build/bin; \ + cd $(CURRENTDIR)src; \ + go build -ldflags="-s -w" -o $(CURRENTDIR)build/bin/$(PKG_NAME); \ + cd $(CURRENTDIR); \ + #upx --brute $(CURRENTDIR)build/bin/$(PKG_NAME) + +clean: + rm -rf -- $(CURRENTDIR)build diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..da4f997 --- /dev/null +++ b/README.txt @@ -0,0 +1,37 @@ +# Weather-NWS + +Get weather data in the US from the National Weather Service + +## Build Dependencies + +- go >= 1.20 + + +## Build + +To build weather-nws, use the following command in the root directory +of this repository + +``` +make +``` + +The resulting binary file will be located at `build/bin/weather-nws + + +## Usage + +``` +weather-nws [STATION_ID] [LAT,LON] +``` + +STATION_ID: 4 letter NWS station ID (example: KVGT for North Las Vegas + Airport) +LAT,LON: Comma seperated latitude and longitude of a particular + location to get weather alerts. South and West are negative. + +### Example: + +``` +weather-nws KVGT 36.1881,-115.1764 +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ac4ddaf --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module weather-nws + +go 1.20 diff --git a/src/conversions.go b/src/conversions.go new file mode 100644 index 0000000..52ba162 --- /dev/null +++ b/src/conversions.go @@ -0,0 +1,108 @@ +package main + +import ( + "math" + "regexp" + "strconv" + "strings" +) + +var ( + metarRegex = regexp.MustCompile(` (\d+)(\d{2})G?(\d{2})?KT .* ([M|-]?\d{2})/([M|-]?\d{2}) `) + weatherRegex = regexp.MustCompile(`\/land\/(day|night)\/([^?]*)`) + whitespacesRegex = regexp.MustCompile(`[^\S\r\n]+`) +) + +// Converts nautical miles into miles +func nmToMi(n float64) float64 { + return n * 1.852 / 1.609344 +} + +// Converts kilometers into miles +func kmToMi(n float64) float64 { + return n / 1.609344 +} + +// Converts miles per hour to meters per second +func miphToMps(n float64) float64 { + return n * 1609.344 / 3600 +} + +// Convert Temperature (t) from Celcius into Fahrenheit +func celciusToFahrenheit(c float64) float64 { + return c*9/5 + 32 +} + +// Temperature (t) MUST be in Celcius +func vaporPressure(t float64) float64 { + return (6.1078 * math.Pow(10, (7.5*t)/(237.3+t))) +} + +// Temperature (t) and Dew Point (d) MUST be in Celcius +// Returns a percentage (between 0 and 100) +func tempDewPointToRelativeHumidity(t, d float64) float64 { + return (vaporPressure(d) / vaporPressure(t) * 100) +} + +func metarStringToWeatherData(m string) (w MetarWeather) { + metarMatch := metarRegex.FindStringSubmatch(strings.ToUpper(m)) + if len(metarMatch) < 6 { + return + } + w.windDirection, _ = strconv.ParseFloat(metarMatch[1], 64) + w.windSpeed, _ = strconv.ParseFloat(metarMatch[2], 64) + w.windGustSpeed, _ = strconv.ParseFloat(metarMatch[3], 64) + w.temperature, _ = strconv.ParseFloat(strings.ReplaceAll(metarMatch[4], "M", "-"), 64) + w.dewPoint, _ = strconv.ParseFloat(strings.ReplaceAll(metarMatch[5], "M", "-"), 64) + return +} + +func iconStringToWeatherIcon(w string) string { + weatherMatch := weatherRegex.FindStringSubmatch(strings.ToLower(w)) + if len(weatherMatch) < 3 { + return weatherIcons[0] + } + for i := range weatherIconRef { + for _, icon := range weatherIconRef[i] { + if icon == weatherMatch[2] { + return weatherIcons[i] + } + } + } + return weatherIcons[0] +} + +func getWindChill(t, ws float64) (wc float64) { + wc = t + if t <= 50 && ws > 3 { + wc = (35.74 + 0.6215*t - + 34.75*math.Pow(ws, 0.16) + + 0.4275*t*math.Pow(ws, 0.16)) + } + return +} + +func getHeatIndex(t, rh float64) (hi float64) { + hi = t + if t >= 80 { + hi = (-42.379 + 2.04901523*t + 10.14333127*rh - + 0.22475541*t*rh - 0.00683783*t*t - 0.05481717*rh*rh + + 0.00122874*t*t*rh + 0.00085282*t*rh*rh - + 0.00000199*t*t*rh*rh) + if t <= 112 && rh < 13 { + hi -= (13 - rh) / 4 * math.Sqrt(17-math.Abs(t-95)/19) + } else if t <= 87 && rh >= 85 { + hi += (rh - 85) / 10 * (87 - t) / 5 + } + } + return +} + +func getApparentTemp(t, rh, ws float64) (at float64) { + e := (rh / 100) * 6.105 * math.Exp((17.27*t)/(237.7+t)) + return t + 0.33*e - 0.7*ws - 4 +} + +func removeExtraWhitespaces(s string) string { + return whitespacesRegex.ReplaceAllString(s, " ") +} diff --git a/src/weather-nws.go b/src/weather-nws.go new file mode 100644 index 0000000..0b0c636 --- /dev/null +++ b/src/weather-nws.go @@ -0,0 +1,241 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +var ( + topBarFlag bool + stationID, latitude, longitude string + expectedANSIStart = "\x1b[104;30m " + watchANSIStart = "\x1b[103;3;30m " + warningANSIStart = "\x1b[41;30m " + ansiEnd = " \x1b[0m" + windDirections = []string{"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", + "S", "SSW", "SW", "WSW", "W", "WNW", "N", "NNW"} + + weatherIconRef = [][]string{ + []string{"skc", "wind_skc"}, + []string{"tsra", "tsra_sct", "tsra_hi"}, + []string{"rain", "rain_showers", "rain_showers_hi"}, + []string{"rain_snow", "rain_sleet", "snow_sleet", "fzra", "rain_fzra", "snow_fzra", "sleet"}, + []string{"snow", "blizzard"}, + []string{"dust", "smoke", "haze", "fog"}, + []string{"few", "sct", "wind_few", "wind_sct"}, + []string{"bkn", "ovc", "wind_bkn", "wind_ovc"}, + } + weatherIcons = []string{"CLR", "TS", "RA", "RASN", "SN", "FG", "SCT", "OVC"} +) + +type MetarWeather struct { + windDirection float64 + windSpeed float64 + windGustSpeed float64 + temperature float64 + dewPoint float64 +} + +type WeatherResponse struct { + Properties Weather `json:"properties"` + ID string `json:"id"` + Type string `json:"type"` + Status int `json:"status"` +} + +type Weather struct { + Station string `json:"station"` + RawMessage string `json:"rawMessage"` + TextDescription string `json:"textDescription"` + Icon string `json:"icon"` + Temperature WeatherVal `json:"temperature"` + Dewpoint WeatherVal `json:"dewpoint"` + WindDirection WeatherVal `json:"windDirection"` + WindSpeed WeatherVal `json:"windSpeed"` + WindGust WeatherVal `json:"windGust"` + Timestamp time.Time `json:"timestamp"` +} + +type WeatherVal struct { + Value *float64 `json:"value"` + UnitCode string `json:"unitCode"` +} + +type AlertsResponse struct { + Features []Feature `json:"features"` + Status int `json:"status"` +} + +type Feature struct { + Properties Alert `json:"properties"` +} + +type Alert struct { + Ends time.Time `json:"ends"` + Expires time.Time `json:"expires"` + Urgency string `json:"urgency"` + Event string `json:"event"` + Headline string `json:"headline"` + Description string `json:"description"` +} + +func getWeather() (w WeatherResponse) { + client := http.Client{} + weatherEndPoint := fmt.Sprintf("https://api.weather.gov/stations/%s/observations/latest", + stationID) + req, err := http.NewRequest("GET", weatherEndPoint, nil) + if err != nil { + return + } + req.Header = http.Header{ + "Accept": []string{"application/geo+json"}, + "User-Agent": []string{"(Weather Info, vigrey.com/git/weather, vi@vigrey.com)"}, + } + resp, err := client.Do(req) + if err != nil { + return + } + respBody, _ := io.ReadAll(resp.Body) + json.Unmarshal(respBody, &w) + return +} + +func getWeatherStr(w Weather, mw MetarWeather) (weatherStr string) { + temp := celciusToFahrenheit(mw.temperature) + tempC := *w.Temperature.Value + dp := celciusToFahrenheit(mw.dewPoint) + rh := tempDewPointToRelativeHumidity(mw.temperature, mw.dewPoint) + wd := windDirections[int((mw.windDirection*2/45)+0.5)%16] + ws := nmToMi(mw.windSpeed) + wsM := miphToMps(ws) + wg := nmToMi(mw.windGustSpeed) + hi := getHeatIndex(temp, rh) + wc := getWindChill(temp, ws) + at := celciusToFahrenheit(getApparentTemp(tempC, rh, wsM)) + if topBarFlag { + weatherStr = fmt.Sprintf("%.0f", temp) + if int(at+0.5) != int(temp+0.5) { + weatherStr += fmt.Sprintf(" (A%.0f)", at) + } + if int(hi+0.5) > int(temp+0.5) { + weatherStr += fmt.Sprintf(" (H%.0f)", hi) + } else if int(wc+0.5) < int(temp+0.5) { + weatherStr += fmt.Sprintf(" (W%.0f)", wc) + } + weatherStr += "F " + return + } + weatherStr = fmt.Sprintf("Weather Station: %s\nTime: %s\n\n"+ + "Temperature: %.2fF\nApparent Temperature %.2fF\n", stationID, + w.Timestamp.Format(time.RFC1123), temp, at) + if int(hi+0.5) != int(temp+0.5) { + weatherStr += fmt.Sprintf("Heat Index: %.2fF\n", hi) + } else if int(wc+0.5) != int(temp+0.5) { + weatherStr += fmt.Sprintf("Wind Chill: %.2fF\n", wc) + } + weatherStr += fmt.Sprintf("\n%s\n\nDewPoint: %.2fF\nRelative Humidity: %.2f%%\n"+ + "Wind: %.2f mph %s", w.TextDescription, dp, rh, ws, wd) + if wg > 0 { + weatherStr += fmt.Sprintf("\nWind Gusts: %.2f mph", wg) + } + return +} + +func getWeatherAlerts() (a AlertsResponse) { + client := http.Client{} + alertEndPoint := fmt.Sprintf("https://api.weather.gov/alerts/active?point=%s,%s", + latitude, longitude) + req, err := http.NewRequest("GET", alertEndPoint, nil) + if err != nil { + return + } + req.Header = http.Header{ + "Accept": []string{"application/geo+json"}, + "User-Agent": []string{"(Vi Grey Weather Info, vi@vigrey.com)"}, + } + resp, err := client.Do(req) + if err != nil { + return + } + respBody, _ := io.ReadAll(resp.Body) + json.Unmarshal(respBody, &a) + return +} + +func getAlertStr(a Alert) (alertStr string) { + var ansiStart string + if strings.ToLower(a.Urgency) == "immediate" { + ansiStart = warningANSIStart + } else if strings.ToLower(a.Urgency) == "future" { + ansiStart = watchANSIStart + } else if strings.ToLower(a.Urgency) == "expected" { + ansiStart = expectedANSIStart + } + alertStr = ansiStart + a.Event + ansiEnd + "\n" + if a.Expires.After(a.Ends) { + alertStr += "Expires at " + a.Expires.Format("Jan _2 3:04PM") + "\n\n" + } else { + alertStr += "Ends at " + a.Ends.Format("Jan _2 3:04PM") + "\n\n" + } + alertStr += (a.Headline + "\n\n" + + removeExtraWhitespaces(a.Description)) + return +} + +func handleFlags(flags []string) { + for _, flag := range flags { + if strings.ToLower(flag) == "--topbar" { + topBarFlag = true + } else if len(strings.ToLower(flag)) == 4 { + stationID = strings.ToUpper(flag) + } else if strings.Contains(flag, ",") { + flagSplit := strings.Split(flag, ",") + latitude = flagSplit[0] + longitude = flagSplit[1] + } + } +} + +func validNWSWeather(m string) bool { + return m != "" +} + +func main() { + handleFlags(os.Args[1:]) + if stationID == "" && latitude == "" && longitude == "" { + return + } + if stationID != "" { + w := getWeather() + if w.Status == 0 || w.Status == 200 { + mw := metarStringToWeatherData(w.Properties.RawMessage) + var weatherStr string + weatherStr = getWeatherStr(w.Properties, mw) + if topBarFlag { + iconStr := iconStringToWeatherIcon(w.Properties.Icon) + fmt.Printf(" %s %s\n", iconStr, weatherStr) + return + } + fmt.Println(weatherStr) + } + } + if latitude != "" && longitude != "" { + alerts := getWeatherAlerts() + if alerts.Status == 0 || alerts.Status == 200 { + if len(alerts.Features) > 0 && stationID != "" { + fmt.Println("\n--------------------\n\nAlerts:\n") + } + for x, a := range alerts.Features { + fmt.Println(getAlertStr(a.Properties)) + if x != len(alerts.Features)-1 { + fmt.Println("\n") + } + } + } + } +}