adding radar support and caching the gif as well as invalidating cache on location change

This commit is contained in:
spinach 2026-01-28 12:47:36 -05:00
parent 430a51b659
commit 524d30c797
10 changed files with 307 additions and 16 deletions

View File

@ -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.

View File

@ -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
}

View File

@ -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)

View File

@ -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 {

View File

@ -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
}

13
main.go
View File

@ -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 {

126
radar.go Normal file
View File

@ -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
}

32
radar_cache_test.go Normal file
View File

@ -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")
}
}

14
radar_test.go Normal file
View File

@ -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)
}
}

BIN
weather

Binary file not shown.