Files
HeatGuard/internal/cli/budget.go
vikingowl 1c9db02334 feat: add web UI with full CRUD setup page
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
2026-02-09 10:39:00 +01:00

121 lines
3.2 KiB
Go

package cli
import (
"github.com/cnachtigall/heatwave-autopilot/internal/heat"
"github.com/cnachtigall/heatwave-autopilot/internal/store"
)
// hourWeather holds weather data for a single hour, used for budget computation.
type hourWeather struct {
Hour int
TempC float64
CloudCoverPct float64
SunshineMin float64
}
// roomBudgetResult holds the budget result for a single room in a single hour.
type roomBudgetResult struct {
RoomName string
RoomID int64
Result heat.BudgetResult
}
// computeRoomBudgets computes heat budgets for all rooms in a profile for a given hour.
// It returns per-room results and the worst-case BudgetStatus.
func computeRoomBudgets(profileID int64, w hourWeather, toggles map[string]bool, indoorTempC float64) ([]roomBudgetResult, heat.BudgetStatus) {
rooms, err := db.ListRooms(profileID)
if err != nil || len(rooms) == 0 {
return nil, heat.Comfortable
}
var results []roomBudgetResult
worstStatus := heat.Comfortable
for _, room := range rooms {
budget := computeSingleRoomBudget(room, w, toggles, indoorTempC)
results = append(results, roomBudgetResult{
RoomName: room.Name,
RoomID: room.ID,
Result: budget,
})
if budget.Status > worstStatus {
worstStatus = budget.Status
}
}
return results, worstStatus
}
func computeSingleRoomBudget(room store.Room, w hourWeather, toggles map[string]bool, indoorTempC float64) heat.BudgetResult {
// Devices
devices, _ := db.ListDevices(room.ID)
var heatDevices []heat.Device
for _, d := range devices {
heatDevices = append(heatDevices, heat.Device{
WattsIdle: d.WattsIdle,
WattsTypical: d.WattsTypical,
WattsPeak: d.WattsPeak,
DutyCycle: d.DutyCycle,
})
}
// Determine device mode from toggles
mode := heat.ModeTypical
if toggles["gaming"] {
mode = heat.ModePeak
}
// Occupants
occupants, _ := db.ListOccupants(room.ID)
var heatOccupants []heat.Occupant
for _, o := range occupants {
heatOccupants = append(heatOccupants, heat.Occupant{
Count: o.Count,
Activity: heat.ParseActivityLevel(o.ActivityLevel),
})
}
// AC capacity
acCap, _ := db.GetRoomACCapacity(room.ID)
// Solar params
cloudFactor := 1.0 - (w.CloudCoverPct / 100.0)
sunshineFraction := 0.0
if w.SunshineMin > 0 {
sunshineFraction = w.SunshineMin / 60.0
if sunshineFraction > 1.0 {
sunshineFraction = 1.0
}
}
solar := heat.SolarParams{
AreaSqm: room.AreaSqm,
WindowFraction: room.WindowFraction,
SHGC: room.SHGC,
ShadingFactor: room.ShadingFactor,
OrientationFactor: heat.OrientationFactor(room.Orientation, w.Hour),
CloudFactor: cloudFactor,
SunshineFraction: sunshineFraction,
PeakIrradiance: 800, // W/m² typical clear-sky peak
}
// Ventilation params
volume := room.AreaSqm * room.CeilingHeightM
vent := heat.VentilationParams{
ACH: room.VentilationACH,
VolumeCubicM: volume,
OutdoorTempC: w.TempC,
IndoorTempC: indoorTempC,
RhoCp: 1.2, // kJ/(m³·K) — standard air at sea level
}
return heat.ComputeRoomBudget(heat.BudgetInput{
Devices: heatDevices,
DeviceMode: mode,
Occupants: heatOccupants,
Solar: solar,
Ventilation: vent,
ACCapacityBTUH: acCap,
})
}