diff --git a/location.go b/location.go new file mode 100644 index 0000000..b895d44 --- /dev/null +++ b/location.go @@ -0,0 +1,185 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// free api for reverse geocoding based on IP +const revGeoURL = "http://ip-api.com/json/" + +type Location struct { + Lat float64 + Lon float64 + City string + State string + Country string + Zipcode string + // NWS specific location data + // Station string +} + +func NewLocation() *Location { + return &Location{} +} + +func (l *Location) UnmarshalJSON(b []byte) error { + + var aux struct { + Lat float64 `json:"lat,string"` + Lon float64 `json:"lon,string"` + Address struct { + City string `json:"city"` + State string `json:"state"` + Country string `json:"country"` + Zipcode string `json:"postcode"` + } `json:"address"` + } + + if err := json.Unmarshal(b, &aux); err != nil { + return err + } + + // copy over values + l.Lat = aux.Lat + l.Lon = aux.Lon + l.City = aux.Address.City + l.State = aux.Address.State + l.Country = aux.Address.Country + l.Zipcode = aux.Address.Zipcode + + return nil +} + +func SearchLocations(query string) ([]Location, error) { + // url to perform location queries against + //locations := []Location{} + queryURL := fmt.Sprintf("https://nominatim.openstreetmap.org/search?q=%s&format=jsonv2&addressdetails=1", query) + + req, err := http.NewRequest(http.MethodGet, queryURL, nil) + if err != nil { + return []Location{}, err + } + + req.Header.Set("User-Agent", + "weather/0.1 (https://git.keegandeppe.com/kdeppe/weather; contact=19keegandeppe@gmail.com)") + + client := &http.Client{ + Timeout: 15 * time.Second, + } + + resp, err := client.Do(req) + + if err != nil { + return []Location{}, err + } + defer resp.Body.Close() + + b, err := io.ReadAll(resp.Body) + if err != nil { + return []Location{}, err + } + + var locations []Location + + if err := json.Unmarshal(b, &locations); err != nil { + panic(err) + } + + // l := NewLocation() + // l.UnmarshalJSON(b) + + fmt.Printf("%+v\n", locations) + + return locations, nil +} + +// func (l *Location) getCoords() error { +// +// res, err := http.Get(revGeoURL) +// +// if err != nil { +// return err +// } +// +// body, err := io.ReadAll(res.Body) +// res.Body.Close() +// if res.StatusCode > 299 { +// errMsg := fmt.Sprintf("Request failed with status code: %d\n", res.StatusCode) +// return errors.New(errMsg) +// } +// +// if err != nil { +// return err +// } +// +// // unmarshall response into struct +// if err := json.Unmarshal(body, l); err != nil { +// return err +// } +// +// l.Found = true +// +// return nil +// } +// +// func (l *Location) getStation() error { +// // generate url based on coords +// url := fmt.Sprintf("https://api.weather.gov/points/%f,%f", l.Lat, l.Lon) +// +// res, err := http.Get(url) +// +// if err != nil { +// return err +// } +// +// body, err := io.ReadAll(res.Body) +// res.Body.Close() +// if res.StatusCode > 299 { +// errMsg := fmt.Sprintf("Request failed with status code: %d\n", res.StatusCode) +// return errors.New(errMsg) +// } +// +// if err != nil { +// return err +// } +// +// // send another request to station URL to find closest +// stationURL := gjson.Get(string(body), "properties.observationStations") +// +// res, err = http.Get(stationURL.String()) +// +// if err != nil { +// return err +// } +// +// body, err = io.ReadAll(res.Body) +// res.Body.Close() +// if res.StatusCode > 299 { +// errMsg := fmt.Sprintf("Request failed with status code: %d\n", res.StatusCode) +// return errors.New(errMsg) +// } +// +// if err != nil { +// return err +// } +// +// station := gjson.Get(string(body), "features.0.properties.stationIdentifier") +// +// if station.String() == "" { +// return errors.New("Station not found") +// } +// +// l.Station = station.String() +// +// return nil +// } + +// for debugging +func (l *Location) String() string { + + return fmt.Sprintf("%s, %s %s (%f, %f)", l.City, l.State, l.Zipcode, l.Lat, l.Lon) +} diff --git a/main.go b/main.go index 460580c..bc5b240 100644 --- a/main.go +++ b/main.go @@ -1,187 +1,89 @@ package main import ( - "encoding/json" - "errors" "fmt" - "io" - "net/http" - - "github.com/tidwall/gjson" + "time" ) -// free api for reverse geocoding based on IP -const revGeoURL = "http://ip-api.com/json/" - func main() { + + SearchLocations("Somerville") + + return w := NewWeather() - if err := w.getLatest(); err != nil { - panic(err) - } + // if err := w.getLatest(); err != nil { + // panic(err) + // } fmt.Println(w) } +type HourForecast struct { + StartTime time.Time + EndTime time.Time + Temperature float64 + TemperatureUnit string + ProbabilityOfPercipitation float64 + WindSpeed float64 + WindDirection string + Icon string +} + type Weather struct { - Temperature float64 - Humidity float64 - Conditions string - Icon string *Location - Static bool // + Forecast []*HourForecast + ForecastURL string } func NewWeather() *Weather { l := NewLocation() // try to set location - if err := l.getCoords(); err != nil { - panic(err) - } - - if err := l.getStation(); err != nil { - panic(err) - } + // if err := l.getCoords(); err != nil { + // panic(err) + // } + // + // if err := l.getStation(); err != nil { + // panic(err) + // } return &Weather{ Location: l, } } -func (w *Weather) getLatest() error { - url := fmt.Sprintf("https://api.weather.gov/stations/%s/observations/latest", w.Location.Station) - - res, err := http.Get(url) - - if err != nil { - return err - } - - body, err := io.ReadAll(res.Body) - res.Body.Close() - if res.StatusCode > 299 { - errMsg := fmt.Sprintf("Request failed with status code: %d\n", res.StatusCode) - return errors.New(errMsg) - } - - if err != nil { - return err - } - - temp := gjson.Get(string(body), "properties.temperature.value") - humidity := gjson.Get(string(body), "properties.relativeHumidity.value") - cond := gjson.Get(string(body), "properties.textDescription") - - // convert to Farenheit - w.Temperature = temp.Float()*(9.0/5) + 32 - w.Humidity = humidity.Float() - w.Conditions = cond.String() - - return nil -} - -func (w *Weather) String() string { - return fmt.Sprintf("%s %.1f deg %.1f%% RH in %s, %s", w.Conditions, w.Temperature, w.Humidity, w.Location.City, w.Location.State) -} - -type Location struct { - Lat float32 `json:"lat"` - Lon float32 `json:"lon"` - City string `json:"city"` - State string `json:"region"` - Zipcode string `json:"zip"` - Found bool - // NWS specific location data - Station string -} - -func NewLocation() *Location { - return &Location{} -} - -func (l *Location) getCoords() error { - - res, err := http.Get(revGeoURL) - - if err != nil { - return err - } - - body, err := io.ReadAll(res.Body) - res.Body.Close() - if res.StatusCode > 299 { - errMsg := fmt.Sprintf("Request failed with status code: %d\n", res.StatusCode) - return errors.New(errMsg) - } - - if err != nil { - return err - } - - // unmarshall response into struct - if err := json.Unmarshal(body, l); err != nil { - return err - } - - l.Found = true - - return nil -} - -func (l *Location) getStation() error { - // generate url based on coords - url := fmt.Sprintf("https://api.weather.gov/points/%f,%f", l.Lat, l.Lon) - - res, err := http.Get(url) - - if err != nil { - return err - } - - body, err := io.ReadAll(res.Body) - res.Body.Close() - if res.StatusCode > 299 { - errMsg := fmt.Sprintf("Request failed with status code: %d\n", res.StatusCode) - return errors.New(errMsg) - } - - if err != nil { - return err - } - - // send another request to station URL to find closest - stationURL := gjson.Get(string(body), "properties.observationStations") - - res, err = http.Get(stationURL.String()) - - if err != nil { - return err - } - - body, err = io.ReadAll(res.Body) - res.Body.Close() - if res.StatusCode > 299 { - errMsg := fmt.Sprintf("Request failed with status code: %d\n", res.StatusCode) - return errors.New(errMsg) - } - - if err != nil { - return err - } - - station := gjson.Get(string(body), "features.0.properties.stationIdentifier") - - if station.String() == "" { - return errors.New("Station not found") - } - - l.Station = station.String() - - return nil -} - -// for debugging -func (l *Location) String() string { - - return fmt.Sprintf("%s, %s %s (%f, %f) station: %s", l.City, l.State, l.Zipcode, l.Lat, l.Lon, l.Station) -} +// func (w *Weather) getLatest() error { +// url := fmt.Sprintf("https://api.weather.gov/stations/%s/observations/latest", w.Location.Station) +// +// res, err := http.Get(url) +// +// if err != nil { +// return err +// } +// +// body, err := io.ReadAll(res.Body) +// res.Body.Close() +// if res.StatusCode > 299 { +// errMsg := fmt.Sprintf("Request failed with status code: %d\n", res.StatusCode) +// return errors.New(errMsg) +// } +// +// if err != nil { +// return err +// } +// +// temp := gjson.Get(string(body), "properties.temperature.value") +// humidity := gjson.Get(string(body), "properties.relativeHumidity.value") +// cond := gjson.Get(string(body), "properties.textDescription") +// +// // convert to Farenheit +// w.Temperature = temp.Float()*(9.0/5) + 32 +// w.Humidity = humidity.Float() +// w.Conditions = cond.String() +// +// return nil +// } +// +// func (w *Weather) String() string { +// return fmt.Sprintf("%s %.1f deg %.1f%% RH in %s, %s", w.Conditions, w.Temperature, w.Humidity, w.Location.City, w.Location.State) +// } diff --git a/weather b/weather index 9b5e1c4..632b316 100755 Binary files a/weather and b/weather differ diff --git a/weather.sh b/weather.sh new file mode 100755 index 0000000..9b5e1c4 --- /dev/null +++ b/weather.sh @@ -0,0 +1,462 @@ +#!/bin/bash + +# client version, calls server for actual API info +usage() { + cat <][-p][-i][-l][-h] + +Queries OpenWeatherMapAPI for weather or lat/long +options: + -a, --apikey set the api key to use to KEY + -l, --location print the location being used + -p, --pretty enables nerdfont symbols + -d, --desc prints the conditions text as well + -z, --zipcode returns the weather for ZIPCODE + -s, --search returns the weather for QUERY + -u, --units sets the temperature units + -i, --icon_test tests the icons used + -r, --radar displays the radar for a given location + -f, --forecast displays the forecast a given location + -h, --help show this message + +ZIPCODE sets the location to the given zipcode +to narrow results, include the ISO country code +ex) -z "02139,US" + +QUERY sets the location to the search +QUERY format is "CITY,REGION,COUNTRY" where +EOF +exit $1 +} + +WORKING_DIR="$HOME/.local/share/weather" +WEATHER_FILE=".env" + +TIMEOUT=60 # timeout for info +TIME=$(date +%s) + + +if [[ ! -d "$WORKING_DIR" ]] ; then + mkdir -p "$WORKING_DIR" +fi + +# going to weather directory +cd "$WORKING_DIR" + +print_weather() { + # prints the weather + + # check to see if it is expired + check_expiration + + # checking if description enabled + if [[ "$DESCRIPTION" = true ]] ; then + DESC=$(echo "$CONDITIONS" | awk '{for(i=1;i<=NF;i++){ $i=toupper(substr($i,1,1)) substr($i,2) }}1') + DESC=$(printf '%s ' "$DESC") + fi + + if [[ "$PRETTY" = true ]] ; then + # print with NF icons + icon=$(get_icon "$WEATHER_ICON") + humidity_icon=$(echo -e '\ue373') + + # checking units + if [[ "$UNIT" == "C" ]] ; then + temp_icon=$(echo -e '\ufa03') + else + temp_icon=$(echo -e '\ufa04') + fi + else + # default printing + humidity_icon="% RH" + if [[ "$UNIT" == "C" ]] ; then + temp_icon="*C" + else + temp_icon="*F" + fi + fi + + # printing + printf '%s%s %.1f%s %d%s in %s\n' "$DESC" "$icon" $TEMPERATURE "$temp_icon" $HUMIDITY "$humidity_icon" "$CITY" + + # save before exiting + save_info +} + +print_location() { + # prints the location being used for the weather + + # uses reverse geocoding + LOCATION_URL="https://api.openweathermap.org/geo/1.0/reverse?lat=$LAT&lon=$LON&appid=$API_KEY" + LOCATION=$(curl --silent -fL "$LOCATION_URL" | gojq '.[0] | del(.local_names)') + + name=$(echo "$LOCATION" | gojq '.name' | tr -d '"') + state=$(echo "$LOCATION" | gojq '.state' | tr -d '"') + country=$(echo "$LOCATION" | gojq '.country' | tr -d '"') + + printf 'Location is set to %s in %s, %s\n' "$name" "$state" "$country" +} + +check_expiration() { + # checks if the info is expired + if [[ $TIME -gt $EXPIRATION ]] ; then + # expired + get_weather + fi +} + +get_location() { + # searching for user location + if [[ -n "$SEARCH" ]] ; then + # search + LOCATION_URL="https://api.openweathermap.org/geo/1.0/$SEARCH&appid=$API_KEY&limit=5" + RESULTS=$(curl --silent -fL "$LOCATION_URL") + # check if its an array + is_array=$(echo "$RESULTS" | gojq 'if type=="array" then 1 else 0 end') + + if [[ $is_array -eq 1 ]] ; then + # returned result is an array + + NUM_RESULTS=$(echo "$RESULTS" | gojq '. | length') + if [[ $NUM_RESULTS -gt 1 ]] ; then + # provide menu for multiple results + for ((i=0; i < $NUM_RESULTS; i++)); do + # adding options + res=$(echo "$RESULTS" | gojq ".[$i]") + name=$(echo "$res" | gojq '.name' | tr -d '"') + state=$(echo "$res" | gojq '.state' | tr -d '"') + country=$(echo "$res" | gojq '.country' | tr -d '"') + opt=$(printf '%s %s, %s' "$name" "$state" "$country") + OPTIONS+=($(($i+1)) "$opt") + done + + # executing dialog menu + exec 3>&1 + SELECTION=$(dialog \ + --title 'Multiple Locations Found!' \ + --clear \ + --cancel-label 'Exit' \ + --ok-label 'Select' \ + --menu 'Please select the desired location. If none of the options look correct, try making refining your search by including a country code and/or region' 0 0 4 \ + "${OPTIONS[@]}" \ + 2>&1 1>&3) + exit_status=$? + exec 3>&- + clear + + if [[ $exit_status -eq 1 || $exit_satus -eq 255 ]] ; then + # canceled or escaped + echo "Location not changed" + exit 0 + fi + + # fix index + SELECTION=$(($SELECTION - 1)) + fi + # updating result + RESULTS=$(echo "$RESULTS" | gojq ".[$SELECTION]") + fi + + # Update info + LAT=$(echo "$RESULTS" | gojq ".lat") + LON=$(echo "$RESULTS" | gojq ".lon") + CITY=$(echo "$RESULTS" | gojq ".name" | tr -d '"') + else + # no search, default to user IP + + url="http://ip-api.com/csv/?fields=252" + res=$(curl --silent -fL "$url") + LAT=$(awk -F , '{print $5}' <<<"$res") + LON=$(awk -F , '{print $6}' <<<"$res") + CITY=$(awk -F , '{print $3}' <<<"$res") + fi + + # check + if [[ -z "$LAT" || -z "$LON" ]] ; then + echo "No location found!" >&2 + exit 1 + fi + + # force refresh + EXPIRATION=0 +} + +get_weather() { + # calls api for weather based on $LAT, $LONG + + if [[ -z "$LAT" || -z "$LON" ]] ; then + # no lat or lon + get_location + fi + + if [[ "$UNIT" =~ ^[Cc]$ ]] ; then + # units set to metric + UNIT="C" + UNITS="metric" + elif [[ "$UNIT" =~ ^[Ff]$ || -z "$UNIT" ]] ; then + # imperial (default) + UNIT="F" + UNITS="imperial" + else + printf 'Unit %s unrecognized\n' "$UNIT" >&2 + exit 1 + fi + + WEATHER_URL="https://api.openweathermap.org/data/2.5/weather?lat=$LAT&lon=$LON&appid=$API_KEY&units=$UNITS" + + WEATHER=$(curl --silent -fL "$WEATHER_URL") + + CONDITIONS=$(echo $WEATHER | gojq -r '.weather[0].description') + TEMPERATURE=$(echo $WEATHER | gojq -r '.main.temp') + HUMIDITY=$(echo $WEATHER | gojq -r '.main.humidity') + WEATHER_ICON=$(echo $WEATHER | gojq -r '.weather[0].icon') + EXPIRATION=$(($TIME+$TIMEOUT)) +} + +update_key() { + # updates the API key used + + printf 'Testing API key %s... ' "$API_KEY" + WEATHER_URL="https://api.openweathermap.org/data/2.5/weather?lat=42.3736&lon=71.1097&appid=$API_KEY" + CODE=$(curl --silent "$WEATHER_URL" | gojq '.cod' | tr -d '"') + + if [[ $CODE -eq 401 ]] ; then + # API_KEY error + printf 'Error: Invalid API Key "%s"!\n' "$API_KEY" >&2 + exit 1 + fi + printf 'Success: Key Updated!\n' + + # saving new key + save_info +} + +save_info() { + # saves set env vars to file + printf '%s="%s"\n' \ +"API_KEY" "$API_KEY" \ +"PARSER" "$PARSER" \ +"LAT" "$LAT" \ +"LON" "$LON" \ +"CITY" "$CITY" \ +"TEMPERATURE" "$TEMPERATURE" \ +"HUMIDITY" "$HUMIDITY" \ +"UNIT" "$UNIT" \ +"WEATHER_ICON" "$WEATHER_ICON" \ +"CONDITIONS" "$CONDITIONS" \ +"EXPIRATION" "$EXPIRATION" > "$WEATHER_FILE" +} + +load_info() { + # loads env vars + if [[ ! -e "$WEATHER_FILE" ]] ; then + # generates blank info on fresh installs + + # test for gojq + PARSER=gojq + if ! command -v $PARSER ; then + # test for jq + PARSER=jq + if ! command -v $PARSER ; then + echo "$1 depends on jq or gojq" + exit 1 + fi + fi + save_info + fi + source $WEATHER_FILE + +} + +display_forecast() { + # two step process, gets weather station from NWS, then mpv to play radar + if [[ -z "$LAT" || -z "$LON" ]] ; then + echo "Location not found!" + exit 1 + fi + echo "Loading weather..." + if [[ -n "$PRETTY" ]] ; then + # nerdfont + curl -fsSL "https://v2d.wttr.in/${LAT},${LON}?F" | less -R + else + curl -fsSL "https://wttr.in/${LAT},${LON}?F" | less -R + fi + + exit 0 +} + +display_radar() { + # two step process, gets weather station from NWS, then mpv to play radar + if [[ -z "$LAT" || -z "$LON" ]] ; then + echo "Location not found!" + exit 1 + fi + # grabbing nws station + NWS=$(curl -fsSL "https://api.weather.gov/points/$LAT,$LON") + STATION=$(echo "$NWS" | gojq '.properties.radarStation' | tr -d '"') + + # validation + if [[ -z "$STATION" ]] ; then + echo "NWS Station error!" + exit 1 + fi + # mpv to play weather in fullscreen + echo "Fetching weather for NWS Station $STATION..." + mpv --fs --loop-file "https://radar.weather.gov/ridge/standard/${STATION}_loop.gif" >/dev/null 2>&1 + + exit 0 +} + +get_icon() { + # sets NF symbols + tod=$(echo "$1" | sed --expression='s/[0-9]//g') + conditions=$(echo "$1" | sed --expression='s/[^0-9]//g') + + # getting icon + if [[ "$tod" == "d" ]] ; then + # day icons + case "$conditions" in + "01") echo -e '\ue30d' ;; # clear + "02") echo -e '\ue30c' ;; # scattered clouds + "03") echo -e '\ue302' ;; # broken clouds + "04") echo -e '\ue312' ;; # cloudy + "09") echo -e '\ue309' ;; # showers + "10") echo -e '\ue308' ;; # rain + "11") echo -e '\ue30f' ;; # thunderstorm + "13") echo -e '\uf2dc' ;; # snow + "50") echo -e '\ue303' ;; # mist + * ) echo -e '\ue374' ;; # unknown + esac + elif [[ "$tod" == "n" ]] ; then + # night icons + case "$conditions" in + "01") echo -e '\ue32b' ;; # clear + "02") echo -e '\ue379' ;; # scattered clouds + "03") echo -e '\ue37e' ;; # broken clouds + "04") echo -e '\ue312' ;; # cloudy + "09") echo -e '\ue326' ;; # showers + "10") echo -e '\ue325' ;; # rain + "11") echo -e '\ue32a' ;; # thunderstorm + "13") echo -e '\uf2dc' ;; # snow + "50") echo -e '\ue346' ;; # mist + * ) echo -e '\ue374' ;; # unknown + esac + else + echo "TOD not recognized" + exit 1 + fi +} + +icon_test() { + #tests icons + printf 'Testing weather icons\nIf any look broken, check that NerdFont is installed\n' + + printf '\nDay:\n' + printf 'Clear: \ue30d\n' + printf 'Partly Cloudy: \ue30c\n' + printf 'Cloudy: \ue302\n' + printf 'Very Cloudy: \ue312\n' + printf 'Showers: \ue309\n' + printf 'Rain: \ue308\n' + printf 'Thunderstorm: \ue30f\n' + printf 'Snow: \uf2dc\n' + printf 'Fog: \ue303\n' + # night icons + printf '\nNight:\n' + printf 'Clear: \ue32b\n' + printf 'Partly Cloudy: \ue379\n' + printf 'Cloudy: \ue37e\n' + printf 'Very Cloudy: \ue312\n' + printf 'Showers: \uf2dc\n' + printf 'Rain: \ue325\n' + printf 'Thunderstorm: \ue32a\n' + printf 'Snow: \uf2dc\n' + printf 'Fog: \ue346\n' + + printf '\nAssorted:\n' + printf 'Degrees (F) \ufa04\n' + printf 'Degrees (C) \ufa03\n' + printf '%% Humidity \ue373\n' + exit 0 +} + +# shifting longform +for arg in "$@"; do + shift + case "$arg" in + '--zipcode') set -- "$@" "-z" ;; + '--location') set -- "$@" "-l" ;; + '--apikey') set -- "$@" "-a" ;; + '--coords') set -- "$@" "-c" ;; + '--search') set -- "$@" "-s" ;; + '--units') set -- "$@" "-u" ;; + '--pretty') set -- "$@" "-p" ;; + '--pretty') set -- "$@" "-p" ;; + '--icon_test') set -- "$@" "-i" ;; + '--radar') set -- "$@" "-r" ;; + '--forecast') set -- "$@" "-f" ;; + '--help') set -- "$@" "-h" ;; + *) set -- "$@" "$arg" ;; + esac +done + +# loading the info +load_info + +while getopts "chpldrfiz:s:u:a:" opt; do + case "$opt" in + 'a' ) + API_KEY="$OPTARG" + update_key + ;; + 'u' ) + UNIT="$OPTARG" + ;; + 'l' ) + print_location + exit 0 + ;; + 'z' ) + [ -n "$SEARCH" ] && usage 1 || SEARCH="/zip?zip=$OPTARG" + ;; + 's' ) + [ -n "$SEARCH" ] && usage 1 || SEARCH="/direct?q=$OPTARG" + ;; + 'p' ) + PRETTY=true + ;; + 'd' ) + DESCRIPTION=true + ;; + 'i' ) + icon_test + ;; + 'r' ) + display_radar + ;; + 'f' ) + display_forecast + ;; + 'h' ) + usage 0 + ;; + '?' ) + usage 1 + ;; + esac +done + +# test for APIKEY +if [[ -z "$API_KEY" ]] ; then + echo "No API Key found!" + exit 1 +fi + +if [[ -n "$SEARCH" ]] ; then + # perform search + get_location +fi + +if [[ -z "$QUIET" ]] ; then + print_weather +fi