package cli import ( _ "embed" "fmt" "html/template" "net/http" "strconv" "github.com/cnachtigall/heatwave-autopilot/internal/config" "github.com/cnachtigall/heatwave-autopilot/internal/report" "github.com/cnachtigall/heatwave-autopilot/internal/static" "github.com/cnachtigall/heatwave-autopilot/internal/store" ) //go:embed templates/setup.html.tmpl var setupTmplStr string var setupTmpl *template.Template func init() { funcs := template.FuncMap{ "mul": func(a, b float64) float64 { return a * b }, "eq": func(a, b string) bool { return a == b }, } setupTmpl = template.Must(template.New("setup").Funcs(funcs).Parse(setupTmplStr)) } // acRoomAssignment pairs a room ID with its name for display. type acRoomAssignment struct { RoomID int64 RoomName string } // acUnitView wraps an AC unit with its room assignments for the template. type acUnitView struct { store.ACUnit AssignedRoomIDs []acRoomAssignment } // setupData holds all data for the setup page template. type setupData struct { CSS template.CSS Flash string Profile *store.Profile Profiles []store.Profile Rooms []store.Room Devices []store.Device Occupants []store.Occupant ACUnits []acUnitView Toggles map[string]bool LastFetch string LLMProvider string LLMModel string LLMEndpoint string } func loadSetupData(w http.ResponseWriter, r *http.Request) setupData { sd := setupData{ CSS: template.CSS(static.TailwindCSS), } sd.Flash = getFlash(w, r) sd.Profiles, _ = db.ListProfiles() p, err := getActiveProfile() if err != nil || p == nil { return sd } sd.Profile = p sd.Rooms, _ = db.ListRooms(p.ID) sd.Devices, _ = db.ListAllDevices(p.ID) sd.Occupants, _ = db.ListAllOccupants(p.ID) // Build room name lookup roomNames := make(map[int64]string) for _, r := range sd.Rooms { roomNames[r.ID] = r.Name } acUnits, _ := db.ListACUnits(p.ID) for _, ac := range acUnits { view := acUnitView{ACUnit: ac} roomIDs, _ := db.GetACRoomAssignments(ac.ID) for _, rid := range roomIDs { view.AssignedRoomIDs = append(view.AssignedRoomIDs, acRoomAssignment{ RoomID: rid, RoomName: roomNames[rid], }) } sd.ACUnits = append(sd.ACUnits, view) } toggles, _ := db.GetToggles(p.ID) sd.Toggles = make(map[string]bool) for _, t := range toggles { sd.Toggles[t.Name] = t.Active } lastFetch, err := db.GetLastFetchTime(p.ID, "openmeteo") if err == nil { sd.LastFetch = lastFetch.Format("2006-01-02 15:04") } llmSettings, _ := db.GetLLMSettings() sd.LLMProvider = llmSettings.Provider sd.LLMModel = llmSettings.Model sd.LLMEndpoint = llmSettings.Endpoint return sd } func setupHandler(w http.ResponseWriter, r *http.Request) { sd := loadSetupData(w, r) w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := setupTmpl.Execute(w, sd); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func dashboardHandler(dateStr string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { data, err := buildReportData(dateStr) if err != nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprintf(w, `

Dashboard

Cannot load dashboard

%s

Go to Setup to configure your profile and fetch forecast data.

`, static.TailwindCSS, err.Error()) return } data.ShowNav = true html, err := report.GenerateString(data) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte(html)) } } // --- CRUD Handlers --- func profileAddHandler(w http.ResponseWriter, r *http.Request) { r.ParseForm() name := r.FormValue("name") lat := parseFloatOr(r.FormValue("latitude"), 0) lon := parseFloatOr(r.FormValue("longitude"), 0) tz := r.FormValue("timezone") if tz == "" { tz = "Europe/Berlin" } _, err := db.CreateProfile(name, lat, lon, tz) if err != nil { setFlash(w, "Error creating profile: "+err.Error()) } else { setFlash(w, "Profile "+name+" created.") } http.Redirect(w, r, "/setup#profiles", http.StatusSeeOther) } func profileDeleteHandler(w http.ResponseWriter, r *http.Request) { id := parseIntOr(r.PathValue("id"), 0) if id == 0 { setFlash(w, "Invalid profile ID.") http.Redirect(w, r, "/setup#profiles", http.StatusSeeOther) return } if err := db.DeleteProfile(id); err != nil { setFlash(w, "Error deleting profile: "+err.Error()) } else { setFlash(w, "Profile deleted.") } http.Redirect(w, r, "/setup#profiles", http.StatusSeeOther) } func roomAddHandler(w http.ResponseWriter, r *http.Request) { r.ParseForm() p, err := getActiveProfile() if err != nil { setFlash(w, "No active profile.") http.Redirect(w, r, "/setup#rooms", http.StatusSeeOther) return } name := r.FormValue("name") areaSqm := parseFloatOr(r.FormValue("area_sqm"), 20) floor := int(parseIntOr(r.FormValue("floor"), 0)) orientation := r.FormValue("orientation") shadingType := r.FormValue("shading_type") shadingFactor := parseFloatOr(r.FormValue("shading_factor"), 1.0) ventilation := r.FormValue("ventilation") insulation := r.FormValue("insulation") params := store.RoomParams{ CeilingHeightM: parseFloatOr(r.FormValue("ceiling_height_m"), 2.50), VentilationACH: parseFloatOr(r.FormValue("ventilation_ach"), 1.5), WindowFraction: parseFloatOr(r.FormValue("window_fraction"), 0.30), SHGC: parseFloatOr(r.FormValue("shgc"), 0.60), } _, err = db.CreateRoom(p.ID, name, areaSqm, floor, orientation, shadingType, shadingFactor, ventilation, insulation, params) if err != nil { setFlash(w, "Error creating room: "+err.Error()) } else { setFlash(w, "Room "+name+" created.") } http.Redirect(w, r, "/setup#rooms", http.StatusSeeOther) } func roomDeleteHandler(w http.ResponseWriter, r *http.Request) { id := parseIntOr(r.PathValue("id"), 0) if id == 0 { setFlash(w, "Invalid room ID.") http.Redirect(w, r, "/setup#rooms", http.StatusSeeOther) return } if err := db.DeleteRoom(id); err != nil { setFlash(w, "Error deleting room: "+err.Error()) } else { setFlash(w, "Room deleted.") } http.Redirect(w, r, "/setup#rooms", http.StatusSeeOther) } func deviceAddHandler(w http.ResponseWriter, r *http.Request) { r.ParseForm() roomID := parseIntOr(r.FormValue("room_id"), 0) name := r.FormValue("name") deviceType := r.FormValue("device_type") wattsIdle := parseFloatOr(r.FormValue("watts_idle"), 10) wattsTypical := parseFloatOr(r.FormValue("watts_typical"), 80) wattsPeak := parseFloatOr(r.FormValue("watts_peak"), 200) dutyCycle := parseFloatOr(r.FormValue("duty_cycle"), 0.5) _, err := db.CreateDevice(roomID, name, deviceType, wattsIdle, wattsTypical, wattsPeak, dutyCycle) if err != nil { setFlash(w, "Error creating device: "+err.Error()) } else { setFlash(w, "Device "+name+" created.") } http.Redirect(w, r, "/setup#devices", http.StatusSeeOther) } func deviceDeleteHandler(w http.ResponseWriter, r *http.Request) { id := parseIntOr(r.PathValue("id"), 0) if id == 0 { setFlash(w, "Invalid device ID.") http.Redirect(w, r, "/setup#devices", http.StatusSeeOther) return } if err := db.DeleteDevice(id); err != nil { setFlash(w, "Error deleting device: "+err.Error()) } else { setFlash(w, "Device deleted.") } http.Redirect(w, r, "/setup#devices", http.StatusSeeOther) } func occupantAddHandler(w http.ResponseWriter, r *http.Request) { r.ParseForm() roomID := parseIntOr(r.FormValue("room_id"), 0) count := int(parseIntOr(r.FormValue("count"), 1)) activityLevel := r.FormValue("activity_level") vulnerable := r.FormValue("vulnerable") == "true" _, err := db.CreateOccupant(roomID, count, activityLevel, vulnerable) if err != nil { setFlash(w, "Error creating occupant: "+err.Error()) } else { setFlash(w, "Occupant added.") } http.Redirect(w, r, "/setup#occupants", http.StatusSeeOther) } func occupantDeleteHandler(w http.ResponseWriter, r *http.Request) { id := parseIntOr(r.PathValue("id"), 0) if id == 0 { setFlash(w, "Invalid occupant ID.") http.Redirect(w, r, "/setup#occupants", http.StatusSeeOther) return } if err := db.DeleteOccupant(id); err != nil { setFlash(w, "Error deleting occupant: "+err.Error()) } else { setFlash(w, "Occupant deleted.") } http.Redirect(w, r, "/setup#occupants", http.StatusSeeOther) } func acAddHandler(w http.ResponseWriter, r *http.Request) { r.ParseForm() p, err := getActiveProfile() if err != nil { setFlash(w, "No active profile.") http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) return } name := r.FormValue("name") acType := r.FormValue("ac_type") capacityBTU := parseFloatOr(r.FormValue("capacity_btu"), 12000) efficiencyEER := parseFloatOr(r.FormValue("efficiency_eer"), 10.0) hasDehumidify := r.FormValue("has_dehumidify") == "true" _, err = db.CreateACUnit(p.ID, name, acType, capacityBTU, hasDehumidify, efficiencyEER) if err != nil { setFlash(w, "Error creating AC unit: "+err.Error()) } else { setFlash(w, "AC unit "+name+" created.") } http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) } func acDeleteHandler(w http.ResponseWriter, r *http.Request) { id := parseIntOr(r.PathValue("id"), 0) if id == 0 { setFlash(w, "Invalid AC unit ID.") http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) return } if err := db.DeleteACUnit(id); err != nil { setFlash(w, "Error deleting AC unit: "+err.Error()) } else { setFlash(w, "AC unit deleted.") } http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) } func acAssignHandler(w http.ResponseWriter, r *http.Request) { r.ParseForm() acID := parseIntOr(r.PathValue("id"), 0) roomID := parseIntOr(r.FormValue("room_id"), 0) if acID == 0 || roomID == 0 { setFlash(w, "Invalid AC or room ID.") http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) return } if err := db.AssignACToRoom(acID, roomID); err != nil { setFlash(w, "Error assigning AC: "+err.Error()) } else { setFlash(w, "AC unit assigned to room.") } http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) } func acUnassignHandler(w http.ResponseWriter, r *http.Request) { r.ParseForm() acID := parseIntOr(r.PathValue("id"), 0) roomID := parseIntOr(r.FormValue("room_id"), 0) if acID == 0 || roomID == 0 { setFlash(w, "Invalid AC or room ID.") http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) return } if err := db.UnassignACFromRoom(acID, roomID); err != nil { setFlash(w, "Error unassigning AC: "+err.Error()) } else { setFlash(w, "AC unit unassigned from room.") } http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) } func toggleSetHandler(w http.ResponseWriter, r *http.Request) { r.ParseForm() p, err := getActiveProfile() if err != nil { setFlash(w, "No active profile.") http.Redirect(w, r, "/setup#toggles", http.StatusSeeOther) return } name := r.FormValue("name") active := r.FormValue("active") == "true" if name == "" { setFlash(w, "Toggle name is required.") http.Redirect(w, r, "/setup#toggles", http.StatusSeeOther) return } if err := db.SetToggle(p.ID, name, active); err != nil { setFlash(w, "Error setting toggle: "+err.Error()) } else { state := "OFF" if active { state = "ON" } setFlash(w, fmt.Sprintf("Toggle %q set to %s.", name, state)) } http.Redirect(w, r, "/setup#toggles", http.StatusSeeOther) } func forecastFetchHandler(w http.ResponseWriter, r *http.Request) { p, err := getActiveProfile() if err != nil { setFlash(w, "No active profile.") http.Redirect(w, r, "/setup#forecast", http.StatusSeeOther) return } if err := fetchForecastForProfile(p); err != nil { setFlash(w, "Forecast fetch failed: "+err.Error()) } else { setFlash(w, "Forecast fetched successfully.") } http.Redirect(w, r, "/setup#forecast", http.StatusSeeOther) } func llmSaveHandler(w http.ResponseWriter, r *http.Request) { r.ParseForm() provider := r.FormValue("provider") var model, endpoint, apiKey string switch provider { case "anthropic", "openai", "gemini": model = r.FormValue("cloud_model") apiKey = r.FormValue("api_key") case "ollama": model = r.FormValue("local_model") endpoint = r.FormValue("endpoint") } apiKeyEnc := "" if apiKey != "" { var err error apiKeyEnc, err = config.Encrypt(apiKey) if err != nil { setFlash(w, "Error encrypting API key: "+err.Error()) http.Redirect(w, r, "/setup#llm", http.StatusSeeOther) return } } else { // Preserve existing encrypted key if no new one was submitted. existing, _ := db.GetLLMSettings() apiKeyEnc = existing.APIKeyEnc } ls := &store.LLMSettings{ Provider: provider, Model: model, Endpoint: endpoint, APIKeyEnc: apiKeyEnc, } if err := db.SaveLLMSettings(ls); err != nil { setFlash(w, "Error saving LLM settings: "+err.Error()) } else { setFlash(w, "LLM settings saved.") } http.Redirect(w, r, "/setup#llm", http.StatusSeeOther) } // --- Flash helpers --- const flashCookieName = "heatwave_flash" func setFlash(w http.ResponseWriter, msg string) { http.SetCookie(w, &http.Cookie{ Name: flashCookieName, Value: msg, Path: "/", MaxAge: 10, SameSite: http.SameSiteStrictMode, }) } func getFlash(w http.ResponseWriter, r *http.Request) string { c, err := r.Cookie(flashCookieName) if err != nil { return "" } // Clear the flash cookie immediately http.SetCookie(w, &http.Cookie{ Name: flashCookieName, Value: "", Path: "/", MaxAge: -1, }) return c.Value } // --- Parse helpers --- func parseFloatOr(s string, def float64) float64 { v, err := strconv.ParseFloat(s, 64) if err != nil { return def } return v } func parseIntOr(s string, def int64) int64 { v, err := strconv.ParseInt(s, 10, 64) if err != nil { return def } return v } // registerSetupRoutes registers all setup-related routes on the mux. func registerSetupRoutes(mux *http.ServeMux, dateStr string) { mux.HandleFunc("GET /{$}", dashboardHandler(dateStr)) mux.HandleFunc("GET /setup", setupHandler) mux.HandleFunc("POST /setup/profiles/add", profileAddHandler) mux.HandleFunc("POST /setup/profiles/{id}/delete", profileDeleteHandler) mux.HandleFunc("POST /setup/rooms/add", roomAddHandler) mux.HandleFunc("POST /setup/rooms/{id}/delete", roomDeleteHandler) mux.HandleFunc("POST /setup/devices/add", deviceAddHandler) mux.HandleFunc("POST /setup/devices/{id}/delete", deviceDeleteHandler) mux.HandleFunc("POST /setup/occupants/add", occupantAddHandler) mux.HandleFunc("POST /setup/occupants/{id}/delete", occupantDeleteHandler) mux.HandleFunc("POST /setup/ac/add", acAddHandler) mux.HandleFunc("POST /setup/ac/{id}/delete", acDeleteHandler) mux.HandleFunc("POST /setup/ac/{id}/assign", acAssignHandler) mux.HandleFunc("POST /setup/ac/{id}/unassign", acUnassignHandler) mux.HandleFunc("POST /setup/toggles/set", toggleSetHandler) mux.HandleFunc("POST /setup/forecast/fetch", forecastFetchHandler) mux.HandleFunc("POST /setup/llm/save", llmSaveHandler) }