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
159 lines
4.6 KiB
Go
159 lines
4.6 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/cnachtigall/heatwave-autopilot/internal/action"
|
|
"github.com/cnachtigall/heatwave-autopilot/internal/heat"
|
|
"github.com/cnachtigall/heatwave-autopilot/internal/llm"
|
|
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var (
|
|
heatplanOutput string
|
|
)
|
|
|
|
func init() {
|
|
heatplanCmd := &cobra.Command{
|
|
Use: "heatplan",
|
|
Short: "Generate a 1-page plain-language heat plan",
|
|
}
|
|
|
|
generateCmd := &cobra.Command{
|
|
Use: "generate",
|
|
Short: "Generate a heat plan document",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
p, err := getActiveProfile()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
provider := getLLMProvider()
|
|
if provider.Name() == "none" {
|
|
return fmt.Errorf("LLM not configured. Set llm.provider in config or use --llm flag")
|
|
}
|
|
|
|
dateStr := time.Now().Format("2006-01-02")
|
|
date, _ := time.Parse("2006-01-02", dateStr)
|
|
loc, _ := time.LoadLocation(p.Timezone)
|
|
from := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, loc)
|
|
to := from.Add(24 * time.Hour)
|
|
|
|
forecasts, err := db.GetForecasts(p.ID, from, to, "")
|
|
if err != nil || len(forecasts) == 0 {
|
|
return fmt.Errorf("no forecast data. Run: heatwave forecast fetch")
|
|
}
|
|
|
|
hourlyData := buildHourlyData(forecasts, loc)
|
|
dayRisk := risk.AnalyzeDay(hourlyData, risk.DefaultThresholds())
|
|
|
|
devices, _ := db.ListAllDevices(p.ID)
|
|
var sources []llm.HeatSource
|
|
for _, d := range devices {
|
|
sources = append(sources, llm.HeatSource{Name: d.Name, Watts: d.WattsTypical * d.DutyCycle})
|
|
}
|
|
|
|
acUnits, _ := db.ListACUnits(p.ID)
|
|
var totalACBTU float64
|
|
for _, ac := range acUnits {
|
|
totalACBTU += ac.CapacityBTU
|
|
}
|
|
var totalGainW float64
|
|
for _, s := range sources {
|
|
totalGainW += s.Watts
|
|
}
|
|
|
|
warnings, _ := db.GetActiveWarnings(p.ID, time.Now())
|
|
var warningStrs []string
|
|
for _, w := range warnings {
|
|
warningStrs = append(warningStrs, w.Headline)
|
|
}
|
|
|
|
var riskWindows []llm.RiskWindowSummary
|
|
for _, w := range dayRisk.Windows {
|
|
riskWindows = append(riskWindows, llm.RiskWindowSummary{
|
|
StartHour: w.StartHour, EndHour: w.EndHour, PeakTempC: w.PeakTempC, Level: w.Level.String(),
|
|
})
|
|
}
|
|
|
|
summaryInput := llm.SummaryInput{
|
|
Date: dateStr,
|
|
PeakTempC: dayRisk.PeakTempC,
|
|
MinNightTempC: dayRisk.MinNightTempC,
|
|
RiskLevel: dayRisk.Level.String(),
|
|
TopHeatSources: sources,
|
|
ACHeadroomBTUH: totalACBTU - heat.WattsToBTUH(totalGainW),
|
|
BudgetStatus: heat.Comfortable.String(),
|
|
ActiveWarnings: warningStrs,
|
|
RiskWindows: riskWindows,
|
|
}
|
|
|
|
// Build timeline
|
|
actions, _ := action.LoadDefaultActions()
|
|
toggles, _ := db.GetActiveToggleNames(p.ID)
|
|
var timelineSlots []llm.TimelineSlotSummary
|
|
var actionSummaries []llm.ActionSummary
|
|
|
|
for _, h := range hourlyData {
|
|
ctx := action.HourContext{
|
|
Hour: h.Hour, TempC: h.TempC, HumidityPct: h.HumidityPct,
|
|
IsDay: h.IsDay, RiskLevel: dayRisk.Level,
|
|
BudgetStatus: heat.Comfortable, ActiveToggles: toggles,
|
|
}
|
|
matched := action.SelectActions(actions, ctx)
|
|
var actionNames []string
|
|
for _, a := range matched {
|
|
actionNames = append(actionNames, a.Name)
|
|
actionSummaries = append(actionSummaries, llm.ActionSummary{
|
|
Name: a.Name, Category: string(a.Category), Impact: string(a.Impact), Hour: h.Hour,
|
|
})
|
|
}
|
|
timelineSlots = append(timelineSlots, llm.TimelineSlotSummary{
|
|
Hour: h.Hour, TempC: h.TempC, RiskLevel: dayRisk.Level.String(),
|
|
BudgetStatus: heat.Comfortable.String(), Actions: actionNames,
|
|
})
|
|
}
|
|
|
|
// Care checklist
|
|
occupants, _ := db.ListAllOccupants(p.ID)
|
|
var careList []string
|
|
for _, o := range occupants {
|
|
if o.Vulnerable {
|
|
careList = append(careList, fmt.Sprintf("Check vulnerable occupant (room %d) at 10:00, 14:00, 18:00", o.RoomID))
|
|
}
|
|
}
|
|
|
|
input := llm.HeatPlanInput{
|
|
Summary: summaryInput,
|
|
Timeline: timelineSlots,
|
|
Actions: actionSummaries,
|
|
CareChecklist: careList,
|
|
}
|
|
|
|
result, err := provider.GenerateHeatPlan(context.Background(), input)
|
|
if err != nil {
|
|
return fmt.Errorf("LLM call failed: %w", err)
|
|
}
|
|
|
|
if heatplanOutput != "" {
|
|
if err := os.WriteFile(heatplanOutput, []byte(result), 0o644); err != nil {
|
|
return fmt.Errorf("write output: %w", err)
|
|
}
|
|
fmt.Printf("Heat plan written to %s\n", heatplanOutput)
|
|
} else {
|
|
fmt.Println(result)
|
|
}
|
|
|
|
return nil
|
|
},
|
|
}
|
|
generateCmd.Flags().StringVar(&heatplanOutput, "output", "", "output file path")
|
|
|
|
heatplanCmd.AddCommand(generateCmd)
|
|
rootCmd.AddCommand(heatplanCmd)
|
|
}
|