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