Files
heloha/cmd/heloha-server/main.go
2026-04-25 20:31:55 -05:00

168 lines
4.2 KiB
Go

package main
import (
"context"
"html/template"
"log/slog"
"net/http"
"os"
"sync"
"time"
"github.com/blakeridgway/heloha/internal/radar"
"github.com/blakeridgway/heloha/internal/server"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
const ingestEvery = 2 * time.Minute
var radarSites = []string{
"KTLX", // Oklahoma City, OK
"KINX", // Tulsa, OK
"KVNX", // Vance AFB, OK
"KFDR", // Frederick, OK
"KSRX", // Fort Smith, AR
"KLZK", // Little Rock, AR
"KSHV", // Shreveport, LA
"KFWS", // Fort Worth, TX
"KDYX", // Dyess AFB, TX
"KAMA", // Amarillo, TX
"KLBB", // Lubbock, TX
"KICT", // Wichita, KS
"KDDC", // Dodge City, KS
"KSGF", // Springfield, MO
"KEAX", // Kansas City, MO
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
ctx := context.Background()
fetcher, err := radar.NewFetcher(ctx)
if err != nil {
logger.Error("init fetcher", "err", err)
os.Exit(1)
}
store := server.NewRadarStore()
cache := server.NewTileCache()
ingestAll(ctx, logger, fetcher, store, cache)
go func() {
tick := time.NewTicker(ingestEvery)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
ingestAll(ctx, logger, fetcher, store, cache)
}
}
}()
indexTmpl := template.Must(template.ParseFiles("web/templates/index.html"))
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
indexTmpl.Execute(w, nil)
})
// Composite tile: product + frame in path (new)
r.Get("/api/v1/tile/composite/{product}/{frame}/{z}/{x}/{y}.png", server.CompositeTileHandler(store, cache))
// Legacy composite route (product=reflectivity, frame=latest)
r.Get("/api/v1/tile/composite/{z}/{x}/{y}.png", server.CompositeTileHandler(store, cache))
// Per-site tile
r.Get("/api/v1/tile/{site}/{z}/{x}/{y}.png", server.TileHandler(store, cache))
// Frame metadata for the loop player
r.Get("/api/v1/frames", server.FramesHandler(store))
// Scan time (KTLX reference)
r.Get("/api/v1/scan-time", func(w http.ResponseWriter, r *http.Request) {
p := store.GetLatest("ktlx", "reflectivity")
if p == nil {
w.Write([]byte("—"))
return
}
w.Write([]byte(p.Time.Format("15:04 UTC")))
})
srv := &http.Server{
Addr: ":8080",
Handler: r,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
logger.Info("starting server", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil {
logger.Error("server exited", "err", err)
os.Exit(1)
}
}
// ingestAll fetches reflectivity + velocity for every site concurrently.
func ingestAll(ctx context.Context, logger *slog.Logger, f *radar.Fetcher, store *server.RadarStore, cache *server.TileCache) {
var wg sync.WaitGroup
for _, site := range radarSites {
wg.Add(2)
go func(s string) {
defer wg.Done()
if err := ingestRefl(ctx, logger, f, store, s); err != nil {
logger.Warn("refl ingest failed", "site", s, "err", err)
}
}(site)
go func(s string) {
defer wg.Done()
if err := ingestVel(ctx, logger, f, store, s); err != nil {
logger.Warn("vel ingest failed", "site", s, "err", err)
}
}(site)
}
wg.Wait()
cache.Invalidate()
logger.Info("ingest cycle complete")
}
func ingestRefl(ctx context.Context, logger *slog.Logger, f *radar.Fetcher, store *server.RadarStore, site string) error {
data, err := f.FetchLatest(ctx, site)
if err != nil {
return err
}
p, err := radar.Parse(site, data)
if err != nil {
return err
}
store.Set(site, "reflectivity", p)
logger.Info("refl ok", "site", site, "time", p.Time, "radials", len(p.Radials))
return nil
}
func ingestVel(ctx context.Context, logger *slog.Logger, f *radar.Fetcher, store *server.RadarStore, site string) error {
data, err := f.FetchVelocity(ctx, site)
if err != nil {
return err
}
p, err := radar.ParseVelocity(site, data)
if err != nil {
return err
}
store.Set(site, "velocity", p)
logger.Info("vel ok", "site", site, "time", p.Time)
return nil
}