diff --git a/README.md b/README.md index c00eac0..e3749ec 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,12 @@ go run . --clear-cache # Show all icons used by the app go run . -i go run . --icon-test + +# Display radar loop for the current location (requires mpv) +go run . -R +go run . --radar + +Radar mode caches the GIF on disk (10 minute TTL) under the cache directory. ``` Note: `go run main.go` only compiles that file. Use `go run .` to include the whole package. diff --git a/cache.go b/cache.go index 6210fa0..3bebf32 100644 --- a/cache.go +++ b/cache.go @@ -44,7 +44,10 @@ func (c *Cache) SaveLocation(query string, loc Location) error { Location: loc, UpdatedAt: time.Now().UTC(), } - return writeJSON(filepath.Join(c.dir, locationCacheFile), payload) + if err := writeJSON(filepath.Join(c.dir, locationCacheFile), payload); err != nil { + return err + } + return c.ClearForecastAndRadar() } func (c *Cache) LoadLocation() (Location, bool, error) { @@ -59,6 +62,21 @@ func (c *Cache) LoadLocation() (Location, bool, error) { return payload.Location, true, nil } +func (c *Cache) UpdateLocation(loc Location) error { + path := filepath.Join(c.dir, locationCacheFile) + var payload LocationCache + if err := readJSON(path, &payload); err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("no cached location found to update") + } + return err + } + + payload.Location = loc + payload.UpdatedAt = time.Now().UTC() + return writeJSON(path, payload) +} + func (c *Cache) SaveForecast(loc Location, unit string, period ForecastPeriod) error { if err := c.ensureDir(); err != nil { return err @@ -109,9 +127,19 @@ func (c *Cache) Clear() error { if err := os.Remove(filepath.Join(c.dir, locationCacheFile)); err != nil && !errors.Is(err, os.ErrNotExist) { return err } + return c.ClearForecastAndRadar() +} + +func (c *Cache) ClearForecastAndRadar() error { + if c.dir == "" { + return fmt.Errorf("cache directory is empty") + } if err := os.Remove(filepath.Join(c.dir, forecastCacheFile)); err != nil && !errors.Is(err, os.ErrNotExist) { return err } + if err := os.Remove(filepath.Join(c.dir, "radar.gif")); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } return nil } diff --git a/cache_test.go b/cache_test.go index bc4041c..232872b 100644 --- a/cache_test.go +++ b/cache_test.go @@ -28,6 +28,58 @@ func TestCacheLocationRoundTrip(t *testing.T) { } } +func TestCacheUpdateLocation(t *testing.T) { + tmp := t.TempDir() + cache := NewCache(tmp) + loc := Location{Lat: 1.23, Lon: 4.56, City: "Testville", State: "TS"} + if err := cache.SaveLocation("Testville", loc); err != nil { + t.Fatalf("save location failed: %v", err) + } + + loc.RadarStation = "KBOX" + if err := cache.UpdateLocation(loc); err != nil { + t.Fatalf("update location failed: %v", err) + } + + loaded, ok, err := cache.LoadLocation() + if err != nil { + t.Fatalf("load location failed: %v", err) + } + if !ok { + t.Fatalf("expected cached location") + } + if loaded.RadarStation != "KBOX" { + t.Fatalf("expected radar station to persist, got %q", loaded.RadarStation) + } +} + +func TestSaveLocationClearsForecastAndRadar(t *testing.T) { + tmp := t.TempDir() + cache := NewCache(tmp) + + forecastPath := filepath.Join(tmp, forecastCacheFile) + radarPath := filepath.Join(tmp, "radar.gif") + + if err := os.WriteFile(forecastPath, []byte("x"), 0o644); err != nil { + t.Fatalf("write forecast: %v", err) + } + if err := os.WriteFile(radarPath, []byte("x"), 0o644); err != nil { + t.Fatalf("write radar: %v", err) + } + + loc := Location{Lat: 1.23, Lon: 4.56, City: "Testville", State: "TS"} + if err := cache.SaveLocation("Testville", loc); err != nil { + t.Fatalf("save location failed: %v", err) + } + + if _, err := os.Stat(forecastPath); !os.IsNotExist(err) { + t.Fatalf("expected forecast cache removed, got %v", err) + } + if _, err := os.Stat(radarPath); !os.IsNotExist(err) { + t.Fatalf("expected radar cache removed, got %v", err) + } +} + func TestCacheForecastExpiry(t *testing.T) { tmp := t.TempDir() cache := NewCache(tmp) diff --git a/forecast.go b/forecast.go index d2717c7..ffa59ec 100644 --- a/forecast.go +++ b/forecast.go @@ -85,6 +85,23 @@ func (c *NWSClient) FetchForecast(ctx context.Context, loc Location, unit string }, 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 { diff --git a/location.go b/location.go index 81e119d..4c648fe 100644 --- a/location.go +++ b/location.go @@ -19,13 +19,14 @@ import ( const revGeoURL = "http://ip-api.com/json/" type Location struct { - Lat float64 - Lon float64 - City string - State string - Country string - Zipcode string - DisplayName string + Lat float64 + Lon float64 + City string + State string + Country string + Zipcode string + DisplayName string + RadarStation string } func NewLocation() *Location { @@ -35,14 +36,15 @@ func NewLocation() *Location { func (l *Location) UnmarshalJSON(b []byte) error { var aux struct { - Lat json.RawMessage `json:"lat"` - Lon json.RawMessage `json:"lon"` - DisplayName string `json:"display_name"` - City string `json:"city"` - State string `json:"state"` - Country string `json:"country"` - Zipcode string `json:"zipcode"` - Address struct { + Lat json.RawMessage `json:"lat"` + Lon json.RawMessage `json:"lon"` + DisplayName string `json:"display_name"` + City string `json:"city"` + State string `json:"state"` + Country string `json:"country"` + Zipcode string `json:"zipcode"` + RadarStation string `json:"RadarStation"` + Address struct { City string `json:"city"` Town string `json:"town"` Village string `json:"village"` @@ -82,6 +84,7 @@ func (l *Location) UnmarshalJSON(b []byte) error { l.Country = firstNonEmpty(aux.Address.Country, aux.Country) l.Zipcode = firstNonEmpty(aux.Address.Zipcode, aux.Zipcode) l.DisplayName = firstNonEmpty(aux.DisplayName, aux.City) + l.RadarStation = aux.RadarStation return nil } diff --git a/main.go b/main.go index 5aac31b..a5e6d50 100644 --- a/main.go +++ b/main.go @@ -29,6 +29,7 @@ func run() error { clearCache bool refresh bool iconTest bool + radarMode bool ) flag.StringVar(&search, "s", "", "Search location (e.g. \"Somerville, MA\")") @@ -45,6 +46,8 @@ func run() error { flag.BoolVar(&refresh, "r", false, "Force refresh forecast data") flag.BoolVar(&iconTest, "icon-test", false, "Print all icons used by the program") flag.BoolVar(&iconTest, "i", false, "Print all icons used by the program") + flag.BoolVar(&radarMode, "radar", false, "Display radar loop for current location via mpv") + flag.BoolVar(&radarMode, "R", false, "Display radar loop for current location via mpv") flag.Parse() ua, err := buildUserAgent() @@ -73,6 +76,12 @@ func run() error { return nil } + if radarMode { + if err := requireMPV(); err != nil { + return err + } + } + if clearCache { if err := cache.Clear(); err != nil { return err @@ -104,6 +113,10 @@ func run() error { } } + if radarMode { + return runRadar(ctx, cache, loc, search, ua) + } + client := NewNWSClient(ua) var period ForecastPeriod if refresh { diff --git a/radar.go b/radar.go new file mode 100644 index 0000000..65e1b07 --- /dev/null +++ b/radar.go @@ -0,0 +1,126 @@ +package main + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const radarCacheTTL = 10 * time.Minute + +func requireMPV() error { + if _, err := exec.LookPath("mpv"); err != nil { + return fmt.Errorf("mpv is required for radar mode") + } + return nil +} + +func runRadar(ctx context.Context, cache *Cache, loc Location, searchQuery string, userAgent string) error { + station := strings.TrimSpace(loc.RadarStation) + if station == "" { + client := NewNWSClient(userAgent) + var err error + station, err = client.FetchRadarStation(ctx, loc) + if err != nil { + return err + } + loc.RadarStation = station + if err := cache.UpdateLocation(loc); err != nil { + if searchQuery == "" { + return err + } + if err := cache.SaveLocation(searchQuery, loc); err != nil { + return err + } + } + } + + url, err := radarURL(station) + if err != nil { + return err + } + + radarPath, err := cacheRadarGIF(ctx, cache, url, userAgent) + if err != nil { + return err + } + + cmd := exec.CommandContext(ctx, "mpv", "--fs", "--loop-file", radarPath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + return cmd.Run() +} + +func radarURL(station string) (string, error) { + station = strings.TrimSpace(station) + if station == "" { + return "", fmt.Errorf("radar station not set") + } + station = strings.ToUpper(station) + return fmt.Sprintf("https://radar.weather.gov/ridge/standard/%s_loop.gif", station), nil +} + +func cacheRadarGIF(ctx context.Context, cache *Cache, url, userAgent string) (string, error) { + if err := cache.ensureDir(); err != nil { + return "", err + } + path := filepath.Join(cache.dir, "radar.gif") + if !shouldRefreshFile(path, radarCacheTTL) { + return path, nil + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", userAgent) + + client := &http.Client{Timeout: 20 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return "", fmt.Errorf("radar request failed with status %d", resp.StatusCode) + } + + tmpPath := path + ".tmp" + tmp, err := os.Create(tmpPath) + if err != nil { + return "", err + } + _, copyErr := io.Copy(tmp, resp.Body) + closeErr := tmp.Close() + if copyErr != nil { + _ = os.Remove(tmpPath) + return "", copyErr + } + if closeErr != nil { + _ = os.Remove(tmpPath) + return "", closeErr + } + + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + return "", err + } + + return path, nil +} + +func shouldRefreshFile(path string, ttl time.Duration) bool { + info, err := os.Stat(path) + if err != nil { + return true + } + return time.Since(info.ModTime()) > ttl +} diff --git a/radar_cache_test.go b/radar_cache_test.go new file mode 100644 index 0000000..0e9d640 --- /dev/null +++ b/radar_cache_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestShouldRefreshFile(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "radar.gif") + + if !shouldRefreshFile(path, time.Minute) { + t.Fatalf("expected refresh when file missing") + } + + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatalf("write temp file: %v", err) + } + if shouldRefreshFile(path, time.Minute) { + t.Fatalf("expected no refresh for fresh file") + } + + old := time.Now().Add(-2 * time.Hour) + if err := os.Chtimes(path, old, old); err != nil { + t.Fatalf("chtimes: %v", err) + } + if !shouldRefreshFile(path, time.Minute) { + t.Fatalf("expected refresh for stale file") + } +} diff --git a/radar_test.go b/radar_test.go new file mode 100644 index 0000000..67c4691 --- /dev/null +++ b/radar_test.go @@ -0,0 +1,14 @@ +package main + +import "testing" + +func TestRadarURL(t *testing.T) { + url, err := radarURL("kbox") + if err != nil { + t.Fatalf("radar url error: %v", err) + } + expected := "https://radar.weather.gov/ridge/standard/KBOX_loop.gif" + if url != expected { + t.Fatalf("unexpected radar url: %s", url) + } +} diff --git a/weather b/weather deleted file mode 100755 index c786634..0000000 Binary files a/weather and /dev/null differ