288 lines
6.9 KiB
Go
288 lines
6.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type ForecastPeriod struct {
|
|
StartTime time.Time `json:"start_time"`
|
|
IsDaytime bool `json:"is_daytime"`
|
|
Temp int `json:"temperature"`
|
|
Unit string `json:"unit"`
|
|
Summary string `json:"summary"`
|
|
Pop *int `json:"pop,omitempty"`
|
|
}
|
|
|
|
type NWSClient struct {
|
|
client *http.Client
|
|
userAgent string
|
|
}
|
|
|
|
func NewNWSClient(userAgent string) *NWSClient {
|
|
return &NWSClient{
|
|
client: &http.Client{Timeout: 15 * time.Second},
|
|
userAgent: userAgent,
|
|
}
|
|
}
|
|
|
|
func (c *NWSClient) FetchForecast(ctx context.Context, loc Location, unit string) (ForecastPeriod, error) {
|
|
pointsURL := fmt.Sprintf("https://api.weather.gov/points/%.4f,%.4f", loc.Lat, loc.Lon)
|
|
var pointsResp struct {
|
|
Properties struct {
|
|
ForecastHourly string `json:"forecastHourly"`
|
|
} `json:"properties"`
|
|
}
|
|
|
|
if err := c.getJSON(ctx, pointsURL, &pointsResp); err != nil {
|
|
return ForecastPeriod{}, err
|
|
}
|
|
if pointsResp.Properties.ForecastHourly == "" {
|
|
return ForecastPeriod{}, fmt.Errorf("missing forecast hourly URL from NWS")
|
|
}
|
|
|
|
var forecastResp struct {
|
|
Properties struct {
|
|
Periods []struct {
|
|
StartTime time.Time `json:"startTime"`
|
|
IsDaytime bool `json:"isDaytime"`
|
|
Temp int `json:"temperature"`
|
|
Unit string `json:"temperatureUnit"`
|
|
Summary string `json:"shortForecast"`
|
|
Pop struct {
|
|
Value *int `json:"value"`
|
|
} `json:"probabilityOfPrecipitation"`
|
|
} `json:"periods"`
|
|
} `json:"properties"`
|
|
}
|
|
|
|
if err := c.getJSON(ctx, pointsResp.Properties.ForecastHourly, &forecastResp); err != nil {
|
|
return ForecastPeriod{}, err
|
|
}
|
|
if len(forecastResp.Properties.Periods) == 0 {
|
|
return ForecastPeriod{}, fmt.Errorf("no forecast periods returned by NWS")
|
|
}
|
|
|
|
period := forecastResp.Properties.Periods[0]
|
|
finalUnit := period.Unit
|
|
finalTemp := period.Temp
|
|
if unit == "C" && strings.EqualFold(period.Unit, "F") {
|
|
finalTemp = int((float64(period.Temp) - 32) * 5.0 / 9.0)
|
|
finalUnit = "C"
|
|
}
|
|
|
|
return ForecastPeriod{
|
|
StartTime: period.StartTime,
|
|
IsDaytime: period.IsDaytime,
|
|
Temp: finalTemp,
|
|
Unit: finalUnit,
|
|
Summary: period.Summary,
|
|
Pop: period.Pop.Value,
|
|
}, nil
|
|
}
|
|
|
|
func (c *NWSClient) FetchRadarStation(ctx context.Context, loc Location) (string, error) {
|
|
pointsURL := fmt.Sprintf("https://api.weather.gov/points/%.4f,%.4f", loc.Lat, loc.Lon)
|
|
var pointsResp struct {
|
|
Properties struct {
|
|
RadarStation string `json:"radarStation"`
|
|
} `json:"properties"`
|
|
}
|
|
if err := c.getJSON(ctx, pointsURL, &pointsResp); err != nil {
|
|
return "", err
|
|
}
|
|
station := strings.TrimSpace(pointsResp.Properties.RadarStation)
|
|
if station == "" {
|
|
return "", fmt.Errorf("missing radar station from NWS points")
|
|
}
|
|
return station, nil
|
|
}
|
|
|
|
func (c *NWSClient) getJSON(ctx context.Context, url string, target any) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("User-Agent", c.userAgent)
|
|
req.Header.Set("Accept", "application/geo+json")
|
|
|
|
resp, err := c.client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode > 299 {
|
|
return fmt.Errorf("request failed with status %d", resp.StatusCode)
|
|
}
|
|
|
|
decoder := json.NewDecoder(resp.Body)
|
|
if err := decoder.Decode(target); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func FormatOutput(loc Location, period ForecastPeriod, includeDesc, pretty bool) string {
|
|
conditionText := titleCase(period.Summary)
|
|
temp := formatTemperature(period, pretty)
|
|
popText, popIcon := formatPop(period, pretty)
|
|
|
|
if pretty {
|
|
conditionIcon := iconForForecast(period.Summary, period.IsDaytime)
|
|
condition := conditionIcon
|
|
if includeDesc {
|
|
condition = fmt.Sprintf("%s %s", conditionIcon, conditionText)
|
|
}
|
|
|
|
parts := []string{
|
|
condition,
|
|
temp,
|
|
popIcon + popText,
|
|
"in",
|
|
loc.Label(),
|
|
}
|
|
return joinParts(parts...)
|
|
}
|
|
|
|
condition := conditionText
|
|
if includeDesc {
|
|
condition = fmt.Sprintf("%s", conditionText)
|
|
}
|
|
|
|
parts := []string{
|
|
condition,
|
|
temp,
|
|
popText,
|
|
"in",
|
|
loc.Label(),
|
|
}
|
|
return joinParts(parts...)
|
|
}
|
|
|
|
func titleCase(input string) string {
|
|
parts := strings.Fields(strings.ToLower(input))
|
|
for i, part := range parts {
|
|
if part == "and" || part == "or" || part == "the" || part == "a" || part == "an" {
|
|
continue
|
|
}
|
|
parts[i] = strings.ToUpper(part[:1]) + part[1:]
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func iconForForecast(summary string, isDay bool) string {
|
|
text := strings.ToLower(summary)
|
|
|
|
if strings.Contains(text, "thunder") {
|
|
return "\ue31d"
|
|
}
|
|
if isPrecip(text) {
|
|
if strings.Contains(text, "snow") || strings.Contains(text, "sleet") || strings.Contains(text, "flurr") {
|
|
return "\ue31a"
|
|
}
|
|
return "\ue318"
|
|
}
|
|
if isMostlySunny(text) {
|
|
if isDay {
|
|
return "\ue30c"
|
|
}
|
|
return "\ue379"
|
|
}
|
|
if isPartlySunny(text) {
|
|
if isDay {
|
|
return "\ue30c"
|
|
}
|
|
return "\ue379"
|
|
}
|
|
if isClear(text) {
|
|
if isDay {
|
|
return "\ue30d"
|
|
}
|
|
return "\ue32b"
|
|
}
|
|
if isCloudy(text) {
|
|
return "\ue312"
|
|
}
|
|
if isDay {
|
|
return "\ue30d"
|
|
}
|
|
return "\ue32b"
|
|
}
|
|
|
|
func formatPop(period ForecastPeriod, pretty bool) (string, string) {
|
|
popValue := 0
|
|
if period.Pop != nil {
|
|
popValue = *period.Pop
|
|
}
|
|
|
|
if pretty {
|
|
icon := precipitationIcon(period.Summary, period.IsDaytime)
|
|
return fmt.Sprintf(" %d%%", popValue), icon
|
|
}
|
|
|
|
return fmt.Sprintf("%d%%", popValue), ""
|
|
}
|
|
|
|
func precipitationIcon(summary string, isDay bool) string {
|
|
text := strings.ToLower(summary)
|
|
if strings.Contains(text, "snow") || strings.Contains(text, "sleet") || strings.Contains(text, "flurr") {
|
|
return "\uf2dc"
|
|
}
|
|
if strings.Contains(text, "rain") || strings.Contains(text, "shower") || strings.Contains(text, "drizzle") {
|
|
return "\ue371"
|
|
}
|
|
return "\ue371"
|
|
}
|
|
|
|
func isPrecip(text string) bool {
|
|
return strings.Contains(text, "rain") ||
|
|
strings.Contains(text, "shower") ||
|
|
strings.Contains(text, "drizzle") ||
|
|
strings.Contains(text, "snow") ||
|
|
strings.Contains(text, "sleet") ||
|
|
strings.Contains(text, "flurr") ||
|
|
strings.Contains(text, "ice") ||
|
|
strings.Contains(text, "hail")
|
|
}
|
|
|
|
func isClear(text string) bool {
|
|
return strings.Contains(text, "clear") || strings.Contains(text, "sunny")
|
|
}
|
|
|
|
func isMostlySunny(text string) bool {
|
|
return strings.Contains(text, "mostly sunny") || strings.Contains(text, "mostly clear")
|
|
}
|
|
|
|
func isPartlySunny(text string) bool {
|
|
return strings.Contains(text, "partly sunny") || strings.Contains(text, "partly cloudy")
|
|
}
|
|
|
|
func isCloudy(text string) bool {
|
|
return strings.Contains(text, "cloudy") || strings.Contains(text, "overcast")
|
|
}
|
|
|
|
func formatTemperature(period ForecastPeriod, pretty bool) string {
|
|
unit := strings.ToUpper(strings.TrimSpace(period.Unit))
|
|
if unit == "" {
|
|
unit = "F"
|
|
}
|
|
|
|
return fmt.Sprintf("%d°%s", period.Temp, unit)
|
|
}
|
|
|
|
func joinParts(parts ...string) string {
|
|
trimmed := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
if strings.TrimSpace(part) == "" {
|
|
continue
|
|
}
|
|
trimmed = append(trimmed, part)
|
|
}
|
|
return strings.Join(trimmed, " ")
|
|
}
|