Add server-side rendered setup UI accessible via `heatwave web`. The dashboard is now re-rendered per request and includes a nav bar linking to the new /setup page. Setup provides full CRUD for profiles, rooms, devices, occupants, AC units (with room assignment), scenario toggles, and forecast fetching — all via POST/redirect/GET forms. - Add ShowNav field to DashboardData for conditional nav bar - Extract fetchForecastForProfile() for reuse by web handler - Create setup.html.tmpl with Tailwind-styled entity sections - Create web_handlers.go with 15 route handlers and flash cookies - Switch web.go from pre-rendered to per-request dashboard rendering - Graceful dashboard fallback when no forecast data exists
166 lines
4.3 KiB
Go
166 lines
4.3 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/cnachtigall/heatwave-autopilot/internal/store"
|
|
"github.com/cnachtigall/heatwave-autopilot/internal/weather"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var (
|
|
forecastForce bool
|
|
forecastHours int
|
|
)
|
|
|
|
func init() {
|
|
forecastCmd := &cobra.Command{
|
|
Use: "forecast",
|
|
Short: "Manage weather forecasts",
|
|
}
|
|
|
|
fetchCmd := &cobra.Command{
|
|
Use: "fetch",
|
|
Short: "Fetch weather forecast and warnings",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
p, err := getActiveProfile()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !forecastForce {
|
|
lastFetch, err := db.GetLastFetchTime(p.ID, "openmeteo")
|
|
if err == nil && time.Since(lastFetch) < time.Hour {
|
|
fmt.Printf("Forecast is fresh (last fetched %s). Use --force to refetch.\n", lastFetch.Format("15:04"))
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return fetchForecastForProfile(p)
|
|
},
|
|
}
|
|
fetchCmd.Flags().BoolVar(&forecastForce, "force", false, "force refetch even if recent data exists")
|
|
|
|
showCmd := &cobra.Command{
|
|
Use: "show",
|
|
Short: "Show forecast data",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
p, err := getActiveProfile()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := time.Now()
|
|
from := now
|
|
to := now.Add(time.Duration(forecastHours) * time.Hour)
|
|
forecasts, err := db.GetForecasts(p.ID, from, to, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(forecasts) == 0 {
|
|
fmt.Println("No forecast data. Run: heatwave forecast fetch")
|
|
return nil
|
|
}
|
|
for _, f := range forecasts {
|
|
temp := "n/a"
|
|
if f.TemperatureC != nil {
|
|
temp = fmt.Sprintf("%.1f°C", *f.TemperatureC)
|
|
}
|
|
hum := "n/a"
|
|
if f.HumidityPct != nil {
|
|
hum = fmt.Sprintf("%.0f%%", *f.HumidityPct)
|
|
}
|
|
fmt.Printf(" %s %s %s %s\n", f.Timestamp.Format("02 Jan 15:04"), temp, hum, f.Source)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
showCmd.Flags().IntVar(&forecastHours, "hours", 48, "hours to display")
|
|
|
|
risksCmd := &cobra.Command{
|
|
Use: "risks",
|
|
Short: "Show identified risk windows",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
fmt.Println("Risk analysis requires forecast data. Run: heatwave forecast fetch")
|
|
fmt.Println("Then use: heatwave plan today")
|
|
return nil
|
|
},
|
|
}
|
|
|
|
forecastCmd.AddCommand(fetchCmd, showCmd, risksCmd)
|
|
rootCmd.AddCommand(forecastCmd)
|
|
}
|
|
|
|
// fetchForecastForProfile fetches forecast and warnings for the given profile.
|
|
func fetchForecastForProfile(p *store.Profile) error {
|
|
ctx := context.Background()
|
|
|
|
om := weather.NewOpenMeteo(nil)
|
|
resp, err := om.FetchForecast(ctx, p.Latitude, p.Longitude, p.Timezone)
|
|
if err != nil {
|
|
fmt.Printf("Open-Meteo failed: %v, trying Bright Sky...\n", err)
|
|
bs := weather.NewBrightSky(nil)
|
|
resp, err = bs.FetchForecast(ctx, p.Latitude, p.Longitude, p.Timezone)
|
|
if err != nil {
|
|
return fmt.Errorf("all forecast providers failed: %w", err)
|
|
}
|
|
}
|
|
|
|
count := 0
|
|
for _, h := range resp.Hourly {
|
|
f := &store.Forecast{
|
|
ProfileID: p.ID,
|
|
Timestamp: h.Timestamp,
|
|
TemperatureC: &h.TemperatureC,
|
|
HumidityPct: &h.HumidityPct,
|
|
WindSpeedMs: &h.WindSpeedMs,
|
|
CloudCoverPct: &h.CloudCoverPct,
|
|
PrecipitationMm: &h.PrecipitationMm,
|
|
SunshineMin: &h.SunshineMin,
|
|
PressureHpa: &h.PressureHpa,
|
|
DewPointC: &h.DewPointC,
|
|
ApparentTempC: &h.ApparentTempC,
|
|
Condition: h.Condition,
|
|
Source: resp.Source,
|
|
}
|
|
if err := db.UpsertForecast(f); err != nil {
|
|
return fmt.Errorf("store forecast: %w", err)
|
|
}
|
|
count++
|
|
}
|
|
fmt.Printf("Stored %d hourly forecasts from %s\n", count, resp.Source)
|
|
|
|
dwd := weather.NewDWDWFS(nil)
|
|
warnings, err := dwd.FetchWarnings(ctx, p.Latitude, p.Longitude)
|
|
if err != nil {
|
|
fmt.Printf("Warning fetch failed: %v\n", err)
|
|
} else {
|
|
for _, w := range warnings {
|
|
sw := &store.Warning{
|
|
ProfileID: p.ID,
|
|
WarningID: w.ID,
|
|
EventType: w.EventType,
|
|
Severity: w.Severity,
|
|
Headline: w.Headline,
|
|
Description: w.Description,
|
|
Instruction: w.Instruction,
|
|
Onset: w.Onset,
|
|
Expires: w.Expires,
|
|
}
|
|
if err := db.UpsertWarning(sw); err != nil {
|
|
return fmt.Errorf("store warning: %w", err)
|
|
}
|
|
}
|
|
fmt.Printf("Stored %d heat warnings\n", len(warnings))
|
|
}
|
|
|
|
cutoff := time.Now().Add(-7 * 24 * time.Hour)
|
|
deleted, _ := db.CleanupOldForecasts(cutoff)
|
|
if deleted > 0 && verbose {
|
|
fmt.Printf("Cleaned up %d old forecasts\n", deleted)
|
|
}
|
|
|
|
return nil
|
|
}
|