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
This commit is contained in:
2026-02-09 10:39:00 +01:00
commit 1c9db02334
80 changed files with 9378 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
bin/
node_modules/

16
Makefile Normal file
View File

@@ -0,0 +1,16 @@
.PHONY: build test clean css
BINARY := heatwave
BUILD_DIR := bin
build: css
go build -o $(BUILD_DIR)/$(BINARY) ./cmd/heatwave
test:
go test -race ./...
clean:
rm -rf $(BUILD_DIR)
css:
npx @tailwindcss/cli -i tailwind/input.css -o internal/static/tailwind.css --minify

13
cmd/heatwave/main.go Normal file
View File

@@ -0,0 +1,13 @@
package main
import (
"os"
"github.com/cnachtigall/heatwave-autopilot/internal/cli"
)
func main() {
if err := cli.Execute(); err != nil {
os.Exit(1)
}
}

24
go.mod Normal file
View File

@@ -0,0 +1,24 @@
module github.com/cnachtigall/heatwave-autopilot
go 1.25.7
require (
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.44.3
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.37.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

66
go.sum Normal file
View File

@@ -0,0 +1,66 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

68
internal/action/action.go Normal file
View File

@@ -0,0 +1,68 @@
package action
import (
"github.com/cnachtigall/heatwave-autopilot/internal/heat"
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
)
type Category string
const (
Shading Category = "shading"
Ventilation Category = "ventilation"
InternalGains Category = "internal_gains"
ACStrategy Category = "ac_strategy"
Hydration Category = "hydration"
Care Category = "care"
)
type Effort string
const (
EffortNone Effort = "none"
EffortLow Effort = "low"
EffortMedium Effort = "medium"
EffortHigh Effort = "high"
)
type Impact string
const (
ImpactLow Impact = "low"
ImpactMedium Impact = "medium"
ImpactHigh Impact = "high"
)
type TimeCondition struct {
HourFrom int `yaml:"hour_from"`
HourTo int `yaml:"hour_to"`
MinTempC float64 `yaml:"min_temp_c"`
MaxTempC float64 `yaml:"max_temp_c"`
MinRisk string `yaml:"min_risk"`
BudgetStatus string `yaml:"budget_status"`
NightOnly bool `yaml:"night_only"`
HighHumidity bool `yaml:"high_humidity"`
}
type Action struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Description string `yaml:"description"`
Category Category `yaml:"category"`
Effort Effort `yaml:"effort"`
Impact Impact `yaml:"impact"`
When TimeCondition `yaml:"when"`
DependsOn []string `yaml:"depends_on"`
Toggles []string `yaml:"toggles"`
}
// HourContext holds the context for a specific hour used for action matching.
type HourContext struct {
Hour int
TempC float64
HumidityPct float64
IsDay bool
RiskLevel risk.RiskLevel
BudgetStatus heat.BudgetStatus
ActiveToggles map[string]bool
}

View File

@@ -0,0 +1,23 @@
package action
import (
_ "embed"
"gopkg.in/yaml.v3"
)
//go:embed templates/actions.yaml
var defaultActionsYAML []byte
type actionFile struct {
Actions []Action `yaml:"actions"`
}
// LoadDefaultActions loads the embedded default action templates.
func LoadDefaultActions() ([]Action, error) {
var f actionFile
if err := yaml.Unmarshal(defaultActionsYAML, &f); err != nil {
return nil, err
}
return f.Actions, nil
}

View File

@@ -0,0 +1,41 @@
package action
import "testing"
func TestLoadDefaultActions(t *testing.T) {
actions, err := LoadDefaultActions()
if err != nil {
t.Fatalf("LoadDefaultActions: %v", err)
}
if len(actions) != 10 {
t.Errorf("len = %d, want 10", len(actions))
}
for _, a := range actions {
if a.ID == "" {
t.Error("action has empty ID")
}
if a.Name == "" {
t.Errorf("action %s has empty Name", a.ID)
}
if a.Category == "" {
t.Errorf("action %s has empty Category", a.ID)
}
}
}
func TestLoadDefaultActions_Categories(t *testing.T) {
actions, _ := LoadDefaultActions()
categories := make(map[Category]int)
for _, a := range actions {
categories[a.Category]++
}
if categories[Shading] != 2 {
t.Errorf("Shading actions = %d, want 2", categories[Shading])
}
if categories[Ventilation] != 2 {
t.Errorf("Ventilation actions = %d, want 2", categories[Ventilation])
}
if categories[Care] != 1 {
t.Errorf("Care actions = %d, want 1", categories[Care])
}
}

127
internal/action/selector.go Normal file
View File

@@ -0,0 +1,127 @@
package action
import (
"sort"
"github.com/cnachtigall/heatwave-autopilot/internal/heat"
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
)
// Matches checks if an action's conditions are met for a given hour context.
func Matches(a Action, ctx HourContext) bool {
w := a.When
// Hour range check (only if at least one is non-zero)
if w.HourFrom != 0 || w.HourTo != 0 {
if ctx.Hour < w.HourFrom || ctx.Hour > w.HourTo {
return false
}
}
// Temperature threshold
if w.MinTempC > 0 && ctx.TempC < w.MinTempC {
return false
}
if w.MaxTempC > 0 && ctx.TempC > w.MaxTempC {
return false
}
// Night only
if w.NightOnly && ctx.IsDay {
return false
}
// Risk level
if w.MinRisk != "" {
required := parseRiskLevel(w.MinRisk)
if ctx.RiskLevel < required {
return false
}
}
// Budget status
if w.BudgetStatus != "" {
required := parseBudgetStatus(w.BudgetStatus)
if ctx.BudgetStatus < required {
return false
}
}
// High humidity
if w.HighHumidity && ctx.HumidityPct <= 70 {
return false
}
return true
}
// SelectActions returns all matching actions for a given hour, sorted by priority.
func SelectActions(actions []Action, ctx HourContext) []Action {
var matched []Action
for _, a := range actions {
if Matches(a, ctx) {
matched = append(matched, a)
}
}
sort.Slice(matched, func(i, j int) bool {
return priority(matched[i]) > priority(matched[j])
})
return matched
}
// priority scores an action: impact * 10 + (4 - effort)
func priority(a Action) int {
return impactScore(a.Impact)*10 + (4 - effortScore(a.Effort))
}
func impactScore(i Impact) int {
switch i {
case ImpactHigh:
return 3
case ImpactMedium:
return 2
case ImpactLow:
return 1
default:
return 0
}
}
func effortScore(e Effort) int {
switch e {
case EffortNone:
return 0
case EffortLow:
return 1
case EffortMedium:
return 2
case EffortHigh:
return 3
default:
return 0
}
}
func parseRiskLevel(s string) risk.RiskLevel {
switch s {
case "moderate":
return risk.Moderate
case "high":
return risk.High
case "extreme":
return risk.Extreme
default:
return risk.Low
}
}
func parseBudgetStatus(s string) heat.BudgetStatus {
switch s {
case "marginal":
return heat.Marginal
case "overloaded":
return heat.Overloaded
default:
return heat.Comfortable
}
}

View File

@@ -0,0 +1,100 @@
package action
import (
"testing"
"github.com/cnachtigall/heatwave-autopilot/internal/heat"
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
)
func TestMatches_TempThreshold(t *testing.T) {
a := Action{When: TimeCondition{MinTempC: 30}}
if Matches(a, HourContext{TempC: 35}) != true {
t.Error("should match at 35C")
}
if Matches(a, HourContext{TempC: 25}) != false {
t.Error("should not match at 25C")
}
}
func TestMatches_HourRange(t *testing.T) {
a := Action{When: TimeCondition{HourFrom: 6, HourTo: 9}}
if Matches(a, HourContext{Hour: 7}) != true {
t.Error("should match at hour 7")
}
if Matches(a, HourContext{Hour: 12}) != false {
t.Error("should not match at hour 12")
}
}
func TestMatches_NightOnly(t *testing.T) {
a := Action{When: TimeCondition{NightOnly: true}}
if Matches(a, HourContext{IsDay: false}) != true {
t.Error("should match at night")
}
if Matches(a, HourContext{IsDay: true}) != false {
t.Error("should not match during day")
}
}
func TestMatches_MinRisk(t *testing.T) {
a := Action{When: TimeCondition{MinRisk: "high"}}
if Matches(a, HourContext{RiskLevel: risk.High}) != true {
t.Error("should match High")
}
if Matches(a, HourContext{RiskLevel: risk.Extreme}) != true {
t.Error("should match Extreme")
}
if Matches(a, HourContext{RiskLevel: risk.Moderate}) != false {
t.Error("should not match Moderate")
}
if Matches(a, HourContext{RiskLevel: risk.Low}) != false {
t.Error("should not match Low")
}
}
func TestMatches_BudgetStatus(t *testing.T) {
a := Action{When: TimeCondition{BudgetStatus: "marginal"}}
if Matches(a, HourContext{BudgetStatus: heat.Marginal}) != true {
t.Error("should match Marginal")
}
if Matches(a, HourContext{BudgetStatus: heat.Overloaded}) != true {
t.Error("should match Overloaded")
}
if Matches(a, HourContext{BudgetStatus: heat.Comfortable}) != false {
t.Error("should not match Comfortable")
}
}
func TestMatches_HighHumidity(t *testing.T) {
a := Action{When: TimeCondition{HighHumidity: true, MinTempC: 26}}
if Matches(a, HourContext{HumidityPct: 80, TempC: 30}) != true {
t.Error("should match at 80% humidity")
}
if Matches(a, HourContext{HumidityPct: 50, TempC: 30}) != false {
t.Error("should not match at 50% humidity")
}
}
func TestSelectActions_SortedByPriority(t *testing.T) {
actions := []Action{
{ID: "low_impact_high_effort", Impact: ImpactLow, Effort: EffortHigh},
{ID: "high_impact_no_effort", Impact: ImpactHigh, Effort: EffortNone},
{ID: "med_impact_low_effort", Impact: ImpactMedium, Effort: EffortLow},
}
ctx := HourContext{TempC: 35, Hour: 12, IsDay: true}
result := SelectActions(actions, ctx)
if len(result) != 3 {
t.Fatalf("len = %d, want 3", len(result))
}
if result[0].ID != "high_impact_no_effort" {
t.Errorf("first = %s, want high_impact_no_effort", result[0].ID)
}
if result[1].ID != "med_impact_low_effort" {
t.Errorf("second = %s, want med_impact_low_effort", result[1].ID)
}
if result[2].ID != "low_impact_high_effort" {
t.Errorf("third = %s, want low_impact_high_effort", result[2].ID)
}
}

View File

@@ -0,0 +1,97 @@
actions:
- id: close_south_shutters
name: "Close south-facing shutters"
description: "Close shutters/blinds on south-facing windows to block direct sun"
category: shading
effort: low
impact: high
when:
hour_from: 0
hour_to: 10
min_temp_c: 25
- id: close_west_shutters
name: "Close west-facing shutters"
description: "Close shutters/blinds on west-facing windows before afternoon sun"
category: shading
effort: low
impact: high
when:
hour_from: 0
hour_to: 14
min_temp_c: 25
- id: night_ventilation
name: "Open windows for night ventilation"
description: "Open windows when outdoor temp drops below indoor temp for passive cooling"
category: ventilation
effort: low
impact: high
when:
night_only: true
- id: close_windows_morning
name: "Close windows in the morning"
description: "Seal the house before outdoor temps rise above indoor"
category: ventilation
effort: none
impact: high
when:
hour_from: 7
hour_to: 8
min_temp_c: 22
- id: gaming_mode_off
name: "Postpone gaming sessions"
description: "Gaming PCs add significant heat; postpone to cooler hours"
category: internal_gains
effort: medium
impact: medium
when:
min_temp_c: 30
budget_status: marginal
toggles: ["gaming"]
- id: defer_cooking
name: "Defer cooking to evening"
description: "Oven and stove add substantial heat; cook in cooler evening hours"
category: internal_gains
effort: medium
impact: medium
when:
hour_from: 11
hour_to: 16
min_temp_c: 30
toggles: ["cooking"]
- id: ac_precool
name: "Pre-cool rooms with AC"
description: "Run AC in early morning to build thermal buffer before peak heat"
category: ac_strategy
effort: none
impact: high
when:
hour_from: 6
hour_to: 9
min_risk: high
- id: ac_dehumidify
name: "Use AC dehumidify mode"
description: "Switch AC to dehumidify mode when humidity is high"
category: ac_strategy
effort: none
impact: medium
when:
min_temp_c: 26
high_humidity: true
- id: hydration_reminder
name: "Hydration reminder"
description: "Drink water regularly; increase intake during heat"
category: hydration
effort: none
impact: medium
when:
min_temp_c: 30
- id: check_vulnerable
name: "Check on vulnerable occupants"
description: "Check on elderly, children, or ill household members"
category: care
effort: low
impact: high
when:
min_temp_c: 30
hour_from: 10
hour_to: 18

View File

@@ -0,0 +1,30 @@
package action
import (
"github.com/cnachtigall/heatwave-autopilot/internal/heat"
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
)
// TimelineSlot represents one hour in a 24-hour plan.
type TimelineSlot struct {
Hour int
Actions []Action
TempC float64
RiskLevel risk.RiskLevel
BudgetStatus heat.BudgetStatus
}
// BuildTimeline creates a 24-slot timeline from hourly contexts and available actions.
func BuildTimeline(contexts []HourContext, actions []Action) []TimelineSlot {
slots := make([]TimelineSlot, len(contexts))
for i, ctx := range contexts {
slots[i] = TimelineSlot{
Hour: ctx.Hour,
TempC: ctx.TempC,
RiskLevel: ctx.RiskLevel,
BudgetStatus: ctx.BudgetStatus,
Actions: SelectActions(actions, ctx),
}
}
return slots
}

View File

@@ -0,0 +1,50 @@
package action
import (
"testing"
"github.com/cnachtigall/heatwave-autopilot/internal/heat"
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
)
func TestBuildTimeline_24Slots(t *testing.T) {
contexts := make([]HourContext, 24)
for i := range contexts {
contexts[i] = HourContext{
Hour: i,
TempC: 20 + float64(i),
RiskLevel: risk.Low,
BudgetStatus: heat.Comfortable,
IsDay: i >= 6 && i < 21,
}
}
actions := []Action{
{ID: "test_action", When: TimeCondition{MinTempC: 30}, Impact: ImpactHigh, Effort: EffortNone},
}
slots := BuildTimeline(contexts, actions)
if len(slots) != 24 {
t.Fatalf("slots = %d, want 24", len(slots))
}
// Check hour assignment
for i, s := range slots {
if s.Hour != i {
t.Errorf("slot[%d].Hour = %d, want %d", i, s.Hour, i)
}
if s.TempC != 20+float64(i) {
t.Errorf("slot[%d].TempC = %v, want %v", i, s.TempC, 20+float64(i))
}
}
// test_action matches hours with temp >= 30 (hours 10-23)
for i, s := range slots {
if i >= 10 && len(s.Actions) == 0 {
t.Errorf("slot[%d] should have actions (temp=%v)", i, s.TempC)
}
if i < 10 && len(s.Actions) > 0 {
t.Errorf("slot[%d] should not have actions (temp=%v)", i, s.TempC)
}
}
}

110
internal/cli/ac.go Normal file
View File

@@ -0,0 +1,110 @@
package cli
import (
"fmt"
"strconv"
"github.com/spf13/cobra"
)
var (
acType string
acBTU float64
acRooms []int64
acDehumidify bool
acEER float64
)
func init() {
acCmd := &cobra.Command{
Use: "ac",
Short: "Manage AC units",
}
addCmd := &cobra.Command{
Use: "add <name>",
Short: "Add an AC unit",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
p, err := getActiveProfile()
if err != nil {
return err
}
ac, err := db.CreateACUnit(p.ID, args[0], acType, acBTU, acDehumidify, acEER)
if err != nil {
return err
}
for _, roomID := range acRooms {
if err := db.AssignACToRoom(ac.ID, roomID); err != nil {
return fmt.Errorf("assign room %d: %w", roomID, err)
}
}
fmt.Printf("AC unit added: %s (ID: %d, %.0f BTU/h, type: %s)\n", ac.Name, ac.ID, ac.CapacityBTU, ac.ACType)
return nil
},
}
addCmd.Flags().StringVar(&acType, "type", "portable", "AC type (portable, split, window, central)")
addCmd.Flags().Float64Var(&acBTU, "btu", 0, "cooling capacity in BTU/h")
addCmd.Flags().Int64SliceVar(&acRooms, "rooms", nil, "room IDs to assign")
addCmd.Flags().BoolVar(&acDehumidify, "dehumidify", false, "has dehumidify mode")
addCmd.Flags().Float64Var(&acEER, "eer", 10.0, "energy efficiency ratio")
addCmd.MarkFlagRequired("btu")
listCmd := &cobra.Command{
Use: "list",
Short: "List AC units",
RunE: func(cmd *cobra.Command, args []string) error {
p, err := getActiveProfile()
if err != nil {
return err
}
units, err := db.ListACUnits(p.ID)
if err != nil {
return err
}
if len(units) == 0 {
fmt.Println("No AC units found")
return nil
}
for _, u := range units {
rooms, _ := db.GetACRoomAssignments(u.ID)
dehumid := ""
if u.HasDehumidify {
dehumid = " +dehumidify"
}
fmt.Printf(" [%d] %s — %.0f BTU/h, type: %s, EER: %.1f%s, rooms: %v\n",
u.ID, u.Name, u.CapacityBTU, u.ACType, u.EfficiencyEER, dehumid, rooms)
}
return nil
},
}
editCmd := &cobra.Command{
Use: "edit <id> <field> <value>",
Short: "Edit an AC unit field",
Args: cobra.ExactArgs(3),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid AC unit ID: %s", args[0])
}
return db.UpdateACUnit(id, args[1], args[2])
},
}
removeCmd := &cobra.Command{
Use: "remove <id>",
Short: "Remove an AC unit",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid AC unit ID: %s", args[0])
}
return db.DeleteACUnit(id)
},
}
acCmd.AddCommand(addCmd, listCmd, editCmd, removeCmd)
rootCmd.AddCommand(acCmd)
}

120
internal/cli/budget.go Normal file
View File

@@ -0,0 +1,120 @@
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,
})
}

106
internal/cli/device.go Normal file
View File

@@ -0,0 +1,106 @@
package cli
import (
"fmt"
"strconv"
"github.com/cnachtigall/heatwave-autopilot/internal/store"
"github.com/spf13/cobra"
)
var (
deviceRoom int64
deviceType string
deviceIdle float64
deviceTypical float64
devicePeak float64
deviceDuty float64
)
func init() {
deviceCmd := &cobra.Command{
Use: "device",
Short: "Manage heat-producing devices",
}
addCmd := &cobra.Command{
Use: "add <name>",
Short: "Add a device",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
d, err := db.CreateDevice(deviceRoom, args[0], deviceType, deviceIdle, deviceTypical, devicePeak, deviceDuty)
if err != nil {
return err
}
fmt.Printf("Device added: %s (ID: %d, typical: %.0fW)\n", d.Name, d.ID, d.WattsTypical)
return nil
},
}
addCmd.Flags().Int64Var(&deviceRoom, "room", 0, "room ID")
addCmd.Flags().StringVar(&deviceType, "type", "other", "device type (pc, monitor, appliance, other)")
addCmd.Flags().Float64Var(&deviceIdle, "watts-idle", 0, "idle power draw (W)")
addCmd.Flags().Float64Var(&deviceTypical, "watts-typical", 0, "typical power draw (W)")
addCmd.Flags().Float64Var(&devicePeak, "watts-peak", 0, "peak power draw (W)")
addCmd.Flags().Float64Var(&deviceDuty, "duty-cycle", 1.0, "duty cycle (0.0-1.0)")
addCmd.MarkFlagRequired("room")
listCmd := &cobra.Command{
Use: "list",
Short: "List devices",
RunE: func(cmd *cobra.Command, args []string) error {
p, err := getActiveProfile()
if err != nil {
return err
}
roomFilter, _ := cmd.Flags().GetInt64("room")
var devices []store.Device
if roomFilter > 0 {
devices, err = db.ListDevices(roomFilter)
} else {
devices, err = db.ListAllDevices(p.ID)
}
if err != nil {
return err
}
if len(devices) == 0 {
fmt.Println("No devices found")
return nil
}
for _, d := range devices {
fmt.Printf(" [%d] %s — type: %s, idle: %.0fW, typical: %.0fW, peak: %.0fW, duty: %.0f%%\n",
d.ID, d.Name, d.DeviceType, d.WattsIdle, d.WattsTypical, d.WattsPeak, d.DutyCycle*100)
}
return nil
},
}
listCmd.Flags().Int64("room", 0, "filter by room ID")
editCmd := &cobra.Command{
Use: "edit <id> <field> <value>",
Short: "Edit a device field",
Args: cobra.ExactArgs(3),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid device ID: %s", args[0])
}
return db.UpdateDevice(id, args[1], args[2])
},
}
removeCmd := &cobra.Command{
Use: "remove <id>",
Short: "Remove a device",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid device ID: %s", args[0])
}
return db.DeleteDevice(id)
},
}
deviceCmd.AddCommand(addCmd, listCmd, editCmd, removeCmd)
rootCmd.AddCommand(deviceCmd)
}

165
internal/cli/forecast.go Normal file
View File

@@ -0,0 +1,165 @@
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
}

158
internal/cli/heatplan.go Normal file
View File

@@ -0,0 +1,158 @@
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)
}

97
internal/cli/occupant.go Normal file
View File

@@ -0,0 +1,97 @@
package cli
import (
"fmt"
"strconv"
"github.com/spf13/cobra"
)
var (
occRoom int64
occCount int
occActivity string
occVuln bool
)
func init() {
occCmd := &cobra.Command{
Use: "occupant",
Short: "Manage room occupants",
}
addCmd := &cobra.Command{
Use: "add",
Short: "Add occupants to a room",
RunE: func(cmd *cobra.Command, args []string) error {
o, err := db.CreateOccupant(occRoom, occCount, occActivity, occVuln)
if err != nil {
return err
}
fmt.Printf("Occupant added (ID: %d, room: %d, count: %d, activity: %s, vulnerable: %v)\n",
o.ID, o.RoomID, o.Count, o.ActivityLevel, o.Vulnerable)
return nil
},
}
addCmd.Flags().Int64Var(&occRoom, "room", 0, "room ID")
addCmd.Flags().IntVar(&occCount, "count", 1, "number of occupants")
addCmd.Flags().StringVar(&occActivity, "activity", "sedentary", "activity level (sleeping, sedentary, light, moderate, heavy)")
addCmd.Flags().BoolVar(&occVuln, "vulnerable", false, "vulnerable occupant (elderly, child, ill)")
addCmd.MarkFlagRequired("room")
listCmd := &cobra.Command{
Use: "list",
Short: "List occupants",
RunE: func(cmd *cobra.Command, args []string) error {
p, err := getActiveProfile()
if err != nil {
return err
}
occupants, err := db.ListAllOccupants(p.ID)
if err != nil {
return err
}
if len(occupants) == 0 {
fmt.Println("No occupants found")
return nil
}
for _, o := range occupants {
vuln := ""
if o.Vulnerable {
vuln = " [VULNERABLE]"
}
fmt.Printf(" [%d] room %d — %d person(s), %s%s\n", o.ID, o.RoomID, o.Count, o.ActivityLevel, vuln)
}
return nil
},
}
editCmd := &cobra.Command{
Use: "edit <id> <field> <value>",
Short: "Edit an occupant field",
Args: cobra.ExactArgs(3),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid occupant ID: %s", args[0])
}
return db.UpdateOccupant(id, args[1], args[2])
},
}
removeCmd := &cobra.Command{
Use: "remove <id>",
Short: "Remove an occupant entry",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid occupant ID: %s", args[0])
}
return db.DeleteOccupant(id)
},
}
occCmd.AddCommand(addCmd, listCmd, editCmd, removeCmd)
rootCmd.AddCommand(occCmd)
}

165
internal/cli/plan.go Normal file
View File

@@ -0,0 +1,165 @@
package cli
import (
"fmt"
"time"
"github.com/cnachtigall/heatwave-autopilot/internal/action"
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
"github.com/spf13/cobra"
)
var planDate string
func init() {
planCmd := &cobra.Command{
Use: "plan",
Short: "Generate action plans",
}
todayCmd := &cobra.Command{
Use: "today",
Short: "Generate today's action plan",
RunE: func(cmd *cobra.Command, args []string) error {
return runPlan(time.Now().Format("2006-01-02"))
},
}
generateCmd := &cobra.Command{
Use: "generate",
Short: "Generate plan for a specific date",
RunE: func(cmd *cobra.Command, args []string) error {
if planDate == "" {
planDate = time.Now().Format("2006-01-02")
}
return runPlan(planDate)
},
}
generateCmd.Flags().StringVar(&planDate, "date", "", "date (YYYY-MM-DD)")
planCmd.AddCommand(todayCmd, generateCmd)
rootCmd.AddCommand(planCmd)
}
func runPlan(dateStr string) error {
p, err := getActiveProfile()
if err != nil {
return err
}
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return fmt.Errorf("invalid date: %s", dateStr)
}
loc, err := time.LoadLocation(p.Timezone)
if err != nil {
loc = time.UTC
}
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 {
return err
}
if len(forecasts) == 0 {
return fmt.Errorf("no forecast data for %s. Run: heatwave forecast fetch", dateStr)
}
// Build hourly risk data
hourlyData := make([]risk.HourlyData, 0, 24)
for _, f := range forecasts {
tempC := 0.0
if f.TemperatureC != nil {
tempC = *f.TemperatureC
}
apparentC := tempC
if f.ApparentTempC != nil {
apparentC = *f.ApparentTempC
}
humPct := 50.0
if f.HumidityPct != nil {
humPct = *f.HumidityPct
}
h := f.Timestamp.In(loc).Hour()
hourlyData = append(hourlyData, risk.HourlyData{
Hour: h,
TempC: tempC,
ApparentC: apparentC,
HumidityPct: humPct,
IsDay: h >= 6 && h < 21,
})
}
// Analyze risks
th := risk.DefaultThresholds()
dayRisk := risk.AnalyzeDay(hourlyData, th)
fmt.Printf("=== Heat Plan for %s ===\n", dateStr)
fmt.Printf("Risk Level: %s | Peak: %.1f°C | Night Min: %.1f°C\n", dayRisk.Level, dayRisk.PeakTempC, dayRisk.MinNightTempC)
if dayRisk.PoorNightCool {
fmt.Println("Warning: Poor nighttime cooling expected")
}
fmt.Println()
if len(dayRisk.Windows) > 0 {
fmt.Println("Risk Windows:")
for _, w := range dayRisk.Windows {
fmt.Printf(" %02d:00%02d:00 | Peak %.1f°C | %s — %s\n", w.StartHour, w.EndHour, w.PeakTempC, w.Level, w.Reason)
}
fmt.Println()
}
// Load actions and build timeline
actions, err := action.LoadDefaultActions()
if err != nil {
return fmt.Errorf("load actions: %w", err)
}
// Get toggles
toggles, _ := db.GetActiveToggleNames(p.ID)
// Build hour contexts with real budget computation
contexts := make([]action.HourContext, 0, len(hourlyData))
for i, h := range hourlyData {
cloudPct := 50.0
sunMin := 0.0
if i < len(forecasts) {
if forecasts[i].CloudCoverPct != nil {
cloudPct = *forecasts[i].CloudCoverPct
}
if forecasts[i].SunshineMin != nil {
sunMin = *forecasts[i].SunshineMin
}
}
w := hourWeather{Hour: h.Hour, TempC: h.TempC, CloudCoverPct: cloudPct, SunshineMin: sunMin}
_, worstStatus := computeRoomBudgets(p.ID, w, toggles, 25.0)
contexts = append(contexts, action.HourContext{
Hour: h.Hour,
TempC: h.TempC,
HumidityPct: h.HumidityPct,
IsDay: h.IsDay,
RiskLevel: dayRisk.Level,
BudgetStatus: worstStatus,
ActiveToggles: toggles,
})
}
timeline := action.BuildTimeline(contexts, actions)
fmt.Println("Hour-by-Hour Plan:")
for _, slot := range timeline {
if len(slot.Actions) == 0 {
continue
}
fmt.Printf(" %02d:00 (%.1f°C):\n", slot.Hour, slot.TempC)
for _, a := range slot.Actions {
fmt.Printf(" - %s [%s, effort: %s]\n", a.Name, a.Impact, a.Effort)
}
}
return nil
}

106
internal/cli/profile.go Normal file
View File

@@ -0,0 +1,106 @@
package cli
import (
"fmt"
"github.com/spf13/cobra"
)
var (
profileLat float64
profileLon float64
profileTZ string
)
func init() {
profileCmd := &cobra.Command{
Use: "profile",
Short: "Manage location profiles",
}
createCmd := &cobra.Command{
Use: "create <name>",
Short: "Create a new location profile",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
p, err := db.CreateProfile(args[0], profileLat, profileLon, profileTZ)
if err != nil {
return err
}
fmt.Printf("Profile created: %s (ID: %d, %.4f, %.4f)\n", p.Name, p.ID, p.Latitude, p.Longitude)
return nil
},
}
createCmd.Flags().Float64Var(&profileLat, "lat", 0, "latitude")
createCmd.Flags().Float64Var(&profileLon, "lon", 0, "longitude")
createCmd.Flags().StringVar(&profileTZ, "tz", "Europe/Berlin", "timezone")
createCmd.MarkFlagRequired("lat")
createCmd.MarkFlagRequired("lon")
showCmd := &cobra.Command{
Use: "show",
Short: "Display current profile",
RunE: func(cmd *cobra.Command, args []string) error {
p, err := getActiveProfile()
if err != nil {
return err
}
fmt.Printf("Profile: %s (ID: %d)\n", p.Name, p.ID)
fmt.Printf(" Location: %.4f, %.4f\n", p.Latitude, p.Longitude)
fmt.Printf(" Timezone: %s\n", p.Timezone)
fmt.Printf(" Created: %s\n", p.CreatedAt.Format("2006-01-02 15:04"))
return nil
},
}
editCmd := &cobra.Command{
Use: "edit <field> <value>",
Short: "Edit a profile field",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
p, err := getActiveProfile()
if err != nil {
return err
}
return db.UpdateProfile(p.ID, args[0], args[1])
},
}
deleteCmd := &cobra.Command{
Use: "delete",
Short: "Delete current profile",
RunE: func(cmd *cobra.Command, args []string) error {
p, err := getActiveProfile()
if err != nil {
return err
}
if err := db.DeleteProfile(p.ID); err != nil {
return err
}
fmt.Printf("Profile %q deleted\n", p.Name)
return nil
},
}
listCmd := &cobra.Command{
Use: "list",
Short: "List all profiles",
RunE: func(cmd *cobra.Command, args []string) error {
profiles, err := db.ListProfiles()
if err != nil {
return err
}
if len(profiles) == 0 {
fmt.Println("No profiles found")
return nil
}
for _, p := range profiles {
fmt.Printf(" [%d] %s (%.4f, %.4f, %s)\n", p.ID, p.Name, p.Latitude, p.Longitude, p.Timezone)
}
return nil
},
}
profileCmd.AddCommand(createCmd, showCmd, editCmd, deleteCmd, listCmd)
rootCmd.AddCommand(profileCmd)
}

331
internal/cli/report.go Normal file
View File

@@ -0,0 +1,331 @@
package cli
import (
"context"
"fmt"
"os"
"os/exec"
"runtime"
"time"
"github.com/cnachtigall/heatwave-autopilot/internal/action"
"github.com/cnachtigall/heatwave-autopilot/internal/llm"
"github.com/cnachtigall/heatwave-autopilot/internal/report"
"github.com/cnachtigall/heatwave-autopilot/internal/risk"
"github.com/cnachtigall/heatwave-autopilot/internal/store"
"github.com/spf13/cobra"
)
var (
reportOutput string
reportDate string
servePort int
serveDate string
serveOpen bool
)
func init() {
reportCmd := &cobra.Command{
Use: "report",
Short: "Generate and serve HTML reports",
}
generateCmd := &cobra.Command{
Use: "generate",
Short: "Generate an HTML heat report",
RunE: func(cmd *cobra.Command, args []string) error {
dateStr := reportDate
if dateStr == "" {
dateStr = time.Now().Format("2006-01-02")
}
data, err := buildReportData(dateStr)
if err != nil {
return err
}
output := reportOutput
if output == "" {
output = fmt.Sprintf("heatwave-report-%s.html", dateStr)
}
f, err := os.Create(output)
if err != nil {
return fmt.Errorf("create output file: %w", err)
}
defer f.Close()
if err := report.Generate(f, data); err != nil {
return fmt.Errorf("generate report: %w", err)
}
fmt.Printf("Report generated: %s\n", output)
return nil
},
}
generateCmd.Flags().StringVar(&reportOutput, "output", "", "output file path (default: heatwave-report-DATE.html)")
generateCmd.Flags().StringVar(&reportDate, "date", "", "date (YYYY-MM-DD, default: today)")
serveCmd := &cobra.Command{
Use: "serve",
Short: "Serve the HTML report via a local HTTP server",
RunE: func(cmd *cobra.Command, args []string) error {
dateStr := serveDate
if dateStr == "" {
dateStr = time.Now().Format("2006-01-02")
}
return runWebServer(dateStr, servePort, serveOpen)
},
}
serveCmd.Flags().IntVar(&servePort, "port", 8080, "HTTP port to serve on")
serveCmd.Flags().StringVar(&serveDate, "date", "", "date (YYYY-MM-DD, default: today)")
serveCmd.Flags().BoolVar(&serveOpen, "open", true, "open browser automatically")
reportCmd.AddCommand(generateCmd, serveCmd)
rootCmd.AddCommand(reportCmd)
}
// buildReportData constructs the full DashboardData from DB + forecast + budget + LLM.
func buildReportData(dateStr string) (report.DashboardData, error) {
p, err := getActiveProfile()
if err != nil {
return report.DashboardData{}, err
}
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return report.DashboardData{}, fmt.Errorf("invalid date: %s", dateStr)
}
loc, err := time.LoadLocation(p.Timezone)
if err != nil {
loc = time.UTC
}
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 {
return report.DashboardData{}, err
}
if len(forecasts) == 0 {
return report.DashboardData{}, fmt.Errorf("no forecast data for %s. Run: heatwave forecast fetch", dateStr)
}
// Build risk analysis
hourlyData := buildHourlyData(forecasts, loc)
th := risk.DefaultThresholds()
dayRisk := risk.AnalyzeDay(hourlyData, th)
// Build actions timeline
actions, _ := action.LoadDefaultActions()
toggles, _ := db.GetActiveToggleNames(p.ID)
// Active warnings
warnings, _ := db.GetActiveWarnings(p.ID, time.Now())
data := report.DashboardData{
GeneratedAt: time.Now(),
ProfileName: p.Name,
Date: dateStr,
RiskLevel: dayRisk.Level.String(),
PeakTempC: dayRisk.PeakTempC,
MinNightTempC: dayRisk.MinNightTempC,
PoorNightCool: dayRisk.PoorNightCool,
}
// Warnings
for _, w := range warnings {
data.Warnings = append(data.Warnings, report.WarningData{
Headline: w.Headline,
Severity: w.Severity,
Description: w.Description,
Instruction: w.Instruction,
Onset: w.Onset.Format("2006-01-02 15:04"),
Expires: w.Expires.Format("2006-01-02 15:04"),
})
}
// Risk windows
for _, w := range dayRisk.Windows {
data.RiskWindows = append(data.RiskWindows, report.RiskWindowData{
StartHour: w.StartHour,
EndHour: w.EndHour,
PeakTempC: w.PeakTempC,
Level: w.Level.String(),
Reason: w.Reason,
})
}
// Timeline + room budget computation
var peakBudgets []roomBudgetResult
for i, h := range hourlyData {
cloudPct := 50.0
sunMin := 0.0
if i < len(forecasts) {
if forecasts[i].CloudCoverPct != nil {
cloudPct = *forecasts[i].CloudCoverPct
}
if forecasts[i].SunshineMin != nil {
sunMin = *forecasts[i].SunshineMin
}
}
w := hourWeather{Hour: h.Hour, TempC: h.TempC, CloudCoverPct: cloudPct, SunshineMin: sunMin}
budgets, worstStatus := computeRoomBudgets(p.ID, w, toggles, 25.0)
ctx := action.HourContext{
Hour: h.Hour,
TempC: h.TempC,
HumidityPct: h.HumidityPct,
IsDay: h.IsDay,
RiskLevel: dayRisk.Level,
BudgetStatus: worstStatus,
ActiveToggles: toggles,
}
matched := action.SelectActions(actions, ctx)
slot := report.TimelineSlotData{
Hour: h.Hour,
HourStr: fmt.Sprintf("%02d:00", h.Hour),
TempC: h.TempC,
RiskLevel: dayRisk.Level.String(),
BudgetStatus: worstStatus.String(),
}
for _, a := range matched {
slot.Actions = append(slot.Actions, report.ActionData{
Name: a.Name,
Category: string(a.Category),
Effort: string(a.Effort),
Impact: string(a.Impact),
Description: a.Description,
})
}
data.Timeline = append(data.Timeline, slot)
// Track peak hour budgets for room budget section
if h.TempC == dayRisk.PeakTempC && len(budgets) > 0 {
peakBudgets = budgets
}
}
// Room budgets (computed at peak temp hour)
for _, rb := range peakBudgets {
data.RoomBudgets = append(data.RoomBudgets, report.RoomBudgetData{
RoomName: rb.RoomName,
InternalGainsW: rb.Result.InternalGainsW,
SolarGainW: rb.Result.SolarGainW,
VentGainW: rb.Result.VentilationGainW,
TotalGainW: rb.Result.TotalGainW,
TotalGainBTUH: rb.Result.TotalGainBTUH,
ACCapacityBTUH: rb.Result.ACCapacityBTUH,
HeadroomBTUH: rb.Result.HeadroomBTUH,
Status: rb.Result.Status.String(),
})
}
// Care checklist
occupants, _ := db.ListAllOccupants(p.ID)
for _, o := range occupants {
if o.Vulnerable {
data.CareChecklist = append(data.CareChecklist, fmt.Sprintf("Check vulnerable occupant (room %d) at 10:00, 14:00, 18:00", o.RoomID))
}
}
// LLM summary (optional — gracefully degrades)
provider := getLLMProvider()
if provider.Name() != "noop" {
var warningHeadlines []string
for _, w := range warnings {
warningHeadlines = append(warningHeadlines, w.Headline)
}
var riskWindowSummaries []llm.RiskWindowSummary
for _, rw := range dayRisk.Windows {
riskWindowSummaries = append(riskWindowSummaries, llm.RiskWindowSummary{
StartHour: rw.StartHour,
EndHour: rw.EndHour,
PeakTempC: rw.PeakTempC,
Level: rw.Level.String(),
})
}
summaryBudgetStatus := "comfortable"
var summaryHeadroom float64
if len(peakBudgets) > 0 {
summaryBudgetStatus = peakBudgets[0].Result.Status.String()
summaryHeadroom = peakBudgets[0].Result.HeadroomBTUH
for _, rb := range peakBudgets[1:] {
if rb.Result.Status > peakBudgets[0].Result.Status {
summaryBudgetStatus = rb.Result.Status.String()
}
if rb.Result.HeadroomBTUH < summaryHeadroom {
summaryHeadroom = rb.Result.HeadroomBTUH
}
}
}
summaryInput := llm.SummaryInput{
Date: dateStr,
PeakTempC: dayRisk.PeakTempC,
MinNightTempC: dayRisk.MinNightTempC,
RiskLevel: dayRisk.Level.String(),
ACHeadroomBTUH: summaryHeadroom,
BudgetStatus: summaryBudgetStatus,
ActiveWarnings: warningHeadlines,
RiskWindows: riskWindowSummaries,
}
bgCtx := context.Background()
summary, err := provider.Summarize(bgCtx, summaryInput)
if err != nil {
if verbose {
fmt.Fprintf(os.Stderr, "LLM summary failed: %v\n", err)
}
} else if summary != "" {
data.LLMSummary = summary
data.LLMDisclaimer = "AI-generated summary. Not a substitute for professional advice."
}
}
return data, nil
}
func buildHourlyData(forecasts []store.Forecast, loc *time.Location) []risk.HourlyData {
var data []risk.HourlyData
for _, f := range forecasts {
tempC := 0.0
if f.TemperatureC != nil {
tempC = *f.TemperatureC
}
apparentC := tempC
if f.ApparentTempC != nil {
apparentC = *f.ApparentTempC
}
humPct := 50.0
if f.HumidityPct != nil {
humPct = *f.HumidityPct
}
h := f.Timestamp.In(loc).Hour()
data = append(data, risk.HourlyData{
Hour: h,
TempC: tempC,
ApparentC: apparentC,
HumidityPct: humPct,
IsDay: h >= 6 && h < 21,
})
}
return data
}
func openBrowser(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "linux":
cmd = exec.Command("xdg-open", url)
case "darwin":
cmd = exec.Command("open", url)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default:
return
}
cmd.Start()
}

119
internal/cli/room.go Normal file
View File

@@ -0,0 +1,119 @@
package cli
import (
"fmt"
"strconv"
"github.com/cnachtigall/heatwave-autopilot/internal/store"
"github.com/spf13/cobra"
)
var (
roomSqm float64
roomCeiling float64
roomFloor int
roomOrient string
roomShading string
roomShadeFact float64
roomVent string
roomVentACH float64
roomWinFrac float64
roomSHGC float64
roomInsulation string
)
func init() {
roomCmd := &cobra.Command{
Use: "room",
Short: "Manage rooms",
}
addCmd := &cobra.Command{
Use: "add <name>",
Short: "Add a room",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
p, err := getActiveProfile()
if err != nil {
return err
}
params := store.RoomParams{
CeilingHeightM: roomCeiling,
VentilationACH: roomVentACH,
WindowFraction: roomWinFrac,
SHGC: roomSHGC,
}
r, err := db.CreateRoom(p.ID, args[0], roomSqm, roomFloor, roomOrient, roomShading, roomShadeFact, roomVent, roomInsulation, params)
if err != nil {
return err
}
fmt.Printf("Room added: %s (ID: %d, %.0f m², %s-facing)\n", r.Name, r.ID, r.AreaSqm, r.Orientation)
return nil
},
}
addCmd.Flags().Float64Var(&roomSqm, "sqm", 0, "area in square meters")
addCmd.Flags().Float64Var(&roomCeiling, "ceiling", 2.5, "ceiling height in meters")
addCmd.Flags().IntVar(&roomFloor, "floor", 0, "floor number")
addCmd.Flags().StringVar(&roomOrient, "orientation", "N", "orientation (N/S/E/W/NE/NW/SE/SW)")
addCmd.Flags().StringVar(&roomShading, "shading", "none", "shading type")
addCmd.Flags().Float64Var(&roomShadeFact, "shading-factor", 1.0, "shading factor (0.0-1.0)")
addCmd.Flags().StringVar(&roomVent, "ventilation", "natural", "ventilation type")
addCmd.Flags().Float64Var(&roomVentACH, "ach", 0.5, "air changes per hour")
addCmd.Flags().Float64Var(&roomWinFrac, "window-fraction", 0.15, "window area as fraction of floor area")
addCmd.Flags().Float64Var(&roomSHGC, "shgc", 0.6, "solar heat gain coefficient of glazing")
addCmd.Flags().StringVar(&roomInsulation, "insulation", "average", "insulation level")
addCmd.MarkFlagRequired("sqm")
listCmd := &cobra.Command{
Use: "list",
Short: "List rooms",
RunE: func(cmd *cobra.Command, args []string) error {
p, err := getActiveProfile()
if err != nil {
return err
}
rooms, err := db.ListRooms(p.ID)
if err != nil {
return err
}
if len(rooms) == 0 {
fmt.Println("No rooms found")
return nil
}
for _, r := range rooms {
fmt.Printf(" [%d] %s — %.0f m², floor %d, %s-facing, shading: %s (%.1f)\n",
r.ID, r.Name, r.AreaSqm, r.Floor, r.Orientation, r.ShadingType, r.ShadingFactor)
}
return nil
},
}
editCmd := &cobra.Command{
Use: "edit <id> <field> <value>",
Short: "Edit a room field",
Args: cobra.ExactArgs(3),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid room ID: %s", args[0])
}
return db.UpdateRoom(id, args[1], args[2])
},
}
removeCmd := &cobra.Command{
Use: "remove <id>",
Short: "Remove a room",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid room ID: %s", args[0])
}
return db.DeleteRoom(id)
},
}
roomCmd.AddCommand(addCmd, listCmd, editCmd, removeCmd)
rootCmd.AddCommand(roomCmd)
}

113
internal/cli/root.go Normal file
View File

@@ -0,0 +1,113 @@
package cli
import (
"fmt"
"os"
"github.com/cnachtigall/heatwave-autopilot/internal/config"
"github.com/cnachtigall/heatwave-autopilot/internal/llm"
"github.com/cnachtigall/heatwave-autopilot/internal/store"
"github.com/spf13/cobra"
)
var (
dbPath string
verbose bool
profileName string
llmFlag string
db *store.Store
cfg config.Config
)
var rootCmd = &cobra.Command{
Use: "heatwave",
Short: "Heatwave Autopilot — personalized heat preparedness",
Long: "A CLI tool that ingests weather forecasts, computes personalized heat budgets, and generates actionable hour-by-hour plans.",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
cfg = config.Load()
if llmFlag != "" {
cfg.LLM.Provider = llmFlag
}
if cmd.Name() == "version" {
return nil
}
path := dbPath
if path == "" {
path = config.DefaultDBPath()
}
if err := os.MkdirAll(config.DataDir(), 0o755); err != nil {
return fmt.Errorf("create data dir: %w", err)
}
var err error
db, err = store.New(path)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
return nil
},
PersistentPostRun: func(cmd *cobra.Command, args []string) {
if db != nil {
db.Close()
}
},
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Heatwave Autopilot — use --help for available commands")
return nil
},
}
func init() {
rootCmd.PersistentFlags().StringVar(&dbPath, "db", "", "path to SQLite database")
rootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "enable verbose output")
rootCmd.PersistentFlags().StringVar(&profileName, "profile", "", "profile name to use")
rootCmd.PersistentFlags().StringVar(&llmFlag, "llm", "", "LLM provider (anthropic, openai, ollama, none)")
}
// Execute runs the root command.
func Execute() error {
return rootCmd.Execute()
}
// getActiveProfile resolves the current profile from --profile flag or first available.
func getActiveProfile() (*store.Profile, error) {
if profileName != "" {
return db.GetProfileByName(profileName)
}
profiles, err := db.ListProfiles()
if err != nil {
return nil, err
}
if len(profiles) == 0 {
return nil, fmt.Errorf("no profiles found — create one with: heatwave profile create <name> --lat <lat> --lon <lon>")
}
return &profiles[0], nil
}
// getLLMProvider creates an LLM provider based on config.
func getLLMProvider() llm.Provider {
switch cfg.LLM.Provider {
case "anthropic":
key := os.Getenv("ANTHROPIC_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "Warning: ANTHROPIC_API_KEY not set, LLM features disabled")
return llm.NewNoop()
}
return llm.NewAnthropic(key, cfg.LLM.Model, nil)
case "openai":
key := os.Getenv("OPENAI_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "Warning: OPENAI_API_KEY not set, LLM features disabled")
return llm.NewNoop()
}
return llm.NewOpenAI(key, cfg.LLM.Model, nil)
case "ollama":
return llm.NewOllama(cfg.LLM.Model, cfg.LLM.Endpoint, nil)
default:
return llm.NewNoop()
}
}

117
internal/cli/summary.go Normal file
View File

@@ -0,0 +1,117 @@
package cli
import (
"context"
"fmt"
"time"
"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 summaryDate string
func init() {
summaryCmd := &cobra.Command{
Use: "summary",
Short: "Generate a 3-bullet AI summary of the heat model",
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 := summaryDate
if dateStr == "" {
dateStr = time.Now().Format("2006-01-02")
}
date, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return fmt.Errorf("invalid date: %s", 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 for %s", dateStr)
}
hourlyData := buildHourlyData(forecasts, loc)
dayRisk := risk.AnalyzeDay(hourlyData, risk.DefaultThresholds())
// Build heat sources from devices
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,
})
}
// AC headroom (simplified — sum all AC units)
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
}
headroom := totalACBTU - heat.WattsToBTUH(totalGainW)
// Warnings
warnings, _ := db.GetActiveWarnings(p.ID, time.Now())
var warningStrs []string
for _, w := range warnings {
warningStrs = append(warningStrs, w.Headline)
}
// Risk windows
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(),
})
}
input := llm.SummaryInput{
Date: dateStr,
PeakTempC: dayRisk.PeakTempC,
MinNightTempC: dayRisk.MinNightTempC,
RiskLevel: dayRisk.Level.String(),
TopHeatSources: sources,
ACHeadroomBTUH: headroom,
BudgetStatus: heat.Comfortable.String(),
ActiveWarnings: warningStrs,
RiskWindows: riskWindows,
}
result, err := provider.Summarize(context.Background(), input)
if err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "LLM call failed: %v\nFalling back to raw data:\n", err)
fmt.Printf("Peak: %.1f°C | Night min: %.1f°C | Risk: %s\n", dayRisk.PeakTempC, dayRisk.MinNightTempC, dayRisk.Level)
return nil
}
fmt.Println(result)
return nil
},
}
summaryCmd.Flags().StringVar(&summaryDate, "date", "", "date (YYYY-MM-DD)")
rootCmd.AddCommand(summaryCmd)
}

View File

@@ -0,0 +1,542 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Heatwave Setup</title>
<style>{{.CSS}}</style>
</head>
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<nav class="bg-white dark:bg-gray-800 shadow mb-4">
<div class="container mx-auto flex items-center gap-6 px-4 py-3">
<span class="font-bold text-lg">Heatwave</span>
<a href="/" class="text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Dashboard</a>
<a href="/setup" class="font-medium text-blue-600 dark:text-blue-400 underline">Setup</a>
</div>
</nav>
<div class="container mx-auto py-4 px-4 max-w-4xl">
{{if .Flash}}
<div class="mb-4 p-3 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded">
{{.Flash}}
</div>
{{end}}
<h1 class="text-3xl font-bold mb-2">Setup</h1>
<p class="text-gray-600 dark:text-gray-400 mb-6">
Active profile: <strong>{{if .Profile}}{{.Profile.Name}}{{else}}(none){{end}}</strong>
</p>
<div class="flex flex-wrap gap-2 mb-8 text-sm">
<a href="#profiles" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Profiles</a>
<a href="#rooms" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Rooms</a>
<a href="#devices" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Devices</a>
<a href="#occupants" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Occupants</a>
<a href="#ac" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">AC Units</a>
<a href="#toggles" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Toggles</a>
<a href="#forecast" class="px-3 py-1 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600">Forecast</a>
</div>
{{template "profiles" .}}
{{template "rooms" .}}
{{template "devices" .}}
{{template "occupants" .}}
{{template "ac_units" .}}
{{template "toggles" .}}
{{template "forecast" .}}
<footer class="mt-8 text-center text-xs text-gray-500 dark:text-gray-500 py-4">
<p>Heatwave Autopilot</p>
</footer>
</div>
</body>
</html>
{{define "profiles"}}
<section id="profiles" class="mb-8">
<h2 class="text-xl font-semibold mb-3">Profiles</h2>
{{if .Profiles}}
<div class="overflow-hidden rounded-lg shadow dark:shadow-gray-700 mb-4">
<table>
<thead>
<tr class="dark:bg-gray-800">
<th>Name</th>
<th>Latitude</th>
<th>Longitude</th>
<th>Timezone</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Profiles}}
<tr class="bg-white dark:bg-gray-800">
<td class="font-medium">{{.Name}}</td>
<td>{{printf "%.4f" .Latitude}}</td>
<td>{{printf "%.4f" .Longitude}}</td>
<td>{{.Timezone}}</td>
<td>
<form method="POST" action="/setup/profiles/{{.ID}}/delete" class="inline">
<button type="submit" class="text-red-600 dark:text-red-400 hover:underline text-sm" onclick="return confirm('Delete profile?')">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-gray-500 dark:text-gray-400 mb-4">No profiles yet.</p>
{{end}}
<form method="POST" action="/setup/profiles/add" class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
<h3 class="font-medium mb-3">Add Profile</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input type="text" name="name" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Latitude</label>
<input type="number" step="any" name="latitude" value="52.52" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Longitude</label>
<input type="number" step="any" name="longitude" value="13.405" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Timezone</label>
<input type="text" name="timezone" value="Europe/Berlin" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
</div>
<button type="submit" class="mt-3 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Add Profile</button>
</form>
</section>
{{end}}
{{define "rooms"}}
<section id="rooms" class="mb-8">
<h2 class="text-xl font-semibold mb-3">Rooms</h2>
{{if not .Profile}}
<p class="text-gray-500 dark:text-gray-400">Create a profile first.</p>
{{else}}
{{if .Rooms}}
<div class="overflow-hidden rounded-lg shadow dark:shadow-gray-700 mb-4">
<table>
<thead>
<tr class="dark:bg-gray-800">
<th>Name</th>
<th>Area (m²)</th>
<th>Floor</th>
<th>Orientation</th>
<th>Ceiling (m)</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Rooms}}
<tr class="bg-white dark:bg-gray-800">
<td class="font-medium">{{.Name}}</td>
<td>{{printf "%.1f" .AreaSqm}}</td>
<td>{{.Floor}}</td>
<td>{{.Orientation}}</td>
<td>{{printf "%.2f" .CeilingHeightM}}</td>
<td>
<form method="POST" action="/setup/rooms/{{.ID}}/delete" class="inline">
<button type="submit" class="text-red-600 dark:text-red-400 hover:underline text-sm" onclick="return confirm('Delete room?')">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-gray-500 dark:text-gray-400 mb-4">No rooms yet.</p>
{{end}}
<form method="POST" action="/setup/rooms/add" class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
<h3 class="font-medium mb-3">Add Room</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input type="text" name="name" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Area (m²)</label>
<input type="number" step="any" name="area_sqm" value="20" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Floor</label>
<input type="number" name="floor" value="0" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Orientation</label>
<select name="orientation" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
<option value="south">South</option>
<option value="west">West</option>
<option value="east">East</option>
<option value="north">North</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Ceiling Height (m)</label>
<input type="number" step="any" name="ceiling_height_m" value="2.50" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Window Fraction</label>
<input type="number" step="any" name="window_fraction" value="0.30" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">SHGC</label>
<input type="number" step="any" name="shgc" value="0.60" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Shading Type</label>
<select name="shading_type" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
<option value="none">None</option>
<option value="blinds">Blinds</option>
<option value="shutters">Shutters</option>
<option value="awning">Awning</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Shading Factor</label>
<input type="number" step="any" name="shading_factor" value="1.0" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Ventilation</label>
<select name="ventilation" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
<option value="natural">Natural</option>
<option value="mechanical">Mechanical</option>
<option value="sealed">Sealed</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Ventilation ACH</label>
<input type="number" step="any" name="ventilation_ach" value="1.5" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Insulation</label>
<select name="insulation" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
<option value="standard">Standard</option>
<option value="poor">Poor</option>
<option value="good">Good</option>
</select>
</div>
</div>
<button type="submit" class="mt-3 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Add Room</button>
</form>
{{end}}
</section>
{{end}}
{{define "devices"}}
<section id="devices" class="mb-8">
<h2 class="text-xl font-semibold mb-3">Devices</h2>
{{if not .Profile}}
<p class="text-gray-500 dark:text-gray-400">Create a profile first.</p>
{{else}}
{{if .Devices}}
<div class="overflow-hidden rounded-lg shadow dark:shadow-gray-700 mb-4">
<table>
<thead>
<tr class="dark:bg-gray-800">
<th>Name</th>
<th>Type</th>
<th>Room</th>
<th>Idle (W)</th>
<th>Typical (W)</th>
<th>Peak (W)</th>
<th>Duty</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Devices}}
<tr class="bg-white dark:bg-gray-800">
<td class="font-medium">{{.Name}}</td>
<td>{{.DeviceType}}</td>
<td>{{.RoomID}}</td>
<td>{{printf "%.0f" .WattsIdle}}</td>
<td>{{printf "%.0f" .WattsTypical}}</td>
<td>{{printf "%.0f" .WattsPeak}}</td>
<td>{{printf "%.0f%%" (mul .DutyCycle 100)}}</td>
<td>
<form method="POST" action="/setup/devices/{{.ID}}/delete" class="inline">
<button type="submit" class="text-red-600 dark:text-red-400 hover:underline text-sm" onclick="return confirm('Delete device?')">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-gray-500 dark:text-gray-400 mb-4">No devices yet.</p>
{{end}}
{{if .Rooms}}
<form method="POST" action="/setup/devices/add" class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
<h3 class="font-medium mb-3">Add Device</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Room</label>
<select name="room_id" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
{{range .Rooms}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input type="text" name="name" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Type</label>
<select name="device_type" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
<option value="computer">Computer</option>
<option value="monitor">Monitor</option>
<option value="tv">TV</option>
<option value="console">Console</option>
<option value="lighting">Lighting</option>
<option value="appliance">Appliance</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Watts Idle</label>
<input type="number" step="any" name="watts_idle" value="10" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Watts Typical</label>
<input type="number" step="any" name="watts_typical" value="80" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Watts Peak</label>
<input type="number" step="any" name="watts_peak" value="200" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Duty Cycle (01)</label>
<input type="number" step="any" name="duty_cycle" value="0.5" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
</div>
<button type="submit" class="mt-3 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Add Device</button>
</form>
{{else}}
<p class="text-sm text-gray-500 dark:text-gray-400">Add a room first to create devices.</p>
{{end}}
{{end}}
</section>
{{end}}
{{define "occupants"}}
<section id="occupants" class="mb-8">
<h2 class="text-xl font-semibold mb-3">Occupants</h2>
{{if not .Profile}}
<p class="text-gray-500 dark:text-gray-400">Create a profile first.</p>
{{else}}
{{if .Occupants}}
<div class="overflow-hidden rounded-lg shadow dark:shadow-gray-700 mb-4">
<table>
<thead>
<tr class="dark:bg-gray-800">
<th>Room</th>
<th>Count</th>
<th>Activity</th>
<th>Vulnerable</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Occupants}}
<tr class="bg-white dark:bg-gray-800">
<td>{{.RoomID}}</td>
<td>{{.Count}}</td>
<td>{{.ActivityLevel}}</td>
<td>{{if .Vulnerable}}Yes{{else}}No{{end}}</td>
<td>
<form method="POST" action="/setup/occupants/{{.ID}}/delete" class="inline">
<button type="submit" class="text-red-600 dark:text-red-400 hover:underline text-sm" onclick="return confirm('Delete occupant?')">Delete</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-gray-500 dark:text-gray-400 mb-4">No occupants yet.</p>
{{end}}
{{if .Rooms}}
<form method="POST" action="/setup/occupants/add" class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
<h3 class="font-medium mb-3">Add Occupant</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Room</label>
<select name="room_id" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
{{range .Rooms}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Count</label>
<input type="number" name="count" value="1" min="1" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Activity Level</label>
<select name="activity_level" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
<option value="sedentary">Sedentary</option>
<option value="light">Light</option>
<option value="moderate">Moderate</option>
<option value="active">Active</option>
</select>
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 pb-2">
<input type="checkbox" name="vulnerable" value="true" class="rounded border-gray-300 dark:border-gray-600">
<span class="text-sm text-gray-600 dark:text-gray-400">Vulnerable</span>
</label>
</div>
</div>
<button type="submit" class="mt-3 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Add Occupant</button>
</form>
{{else}}
<p class="text-sm text-gray-500 dark:text-gray-400">Add a room first to create occupants.</p>
{{end}}
{{end}}
</section>
{{end}}
{{define "ac_units"}}
<section id="ac" class="mb-8">
<h2 class="text-xl font-semibold mb-3">AC Units</h2>
{{if not .Profile}}
<p class="text-gray-500 dark:text-gray-400">Create a profile first.</p>
{{else}}
{{if .ACUnits}}
<div class="space-y-3 mb-4">
{{range .ACUnits}}
{{$acID := .ID}}
<div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
<div class="flex justify-between items-start mb-2">
<div>
<span class="font-medium">{{.Name}}</span>
<span class="text-sm text-gray-500 dark:text-gray-400 ml-2">{{.ACType}} — {{printf "%.0f" .CapacityBTU}} BTU/h — EER {{printf "%.1f" .EfficiencyEER}}</span>
{{if .HasDehumidify}}<span class="text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 px-2 py-0.5 rounded ml-1">Dehumidify</span>{{end}}
</div>
<form method="POST" action="/setup/ac/{{.ID}}/delete" class="inline">
<button type="submit" class="text-red-600 dark:text-red-400 hover:underline text-sm" onclick="return confirm('Delete AC unit?')">Delete</button>
</form>
</div>
{{if $.Rooms}}
<form method="POST" action="/setup/ac/{{.ID}}/assign" class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-gray-600 dark:text-gray-400">Assign to:</span>
<select name="room_id" class="px-2 py-1 border dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-sm">
{{range $.Rooms}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
<button type="submit" class="px-2 py-1 bg-green-600 text-white rounded text-sm hover:bg-green-700">Assign</button>
</form>
{{if .AssignedRoomIDs}}
<div class="mt-2 flex flex-wrap gap-1">
{{range .AssignedRoomIDs}}
<form method="POST" action="/setup/ac/{{$acID}}/unassign" class="inline">
<input type="hidden" name="room_id" value="{{.RoomID}}">
<button type="submit" class="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-sm">
{{.RoomName}} <span class="text-red-500">&times;</span>
</button>
</form>
{{end}}
</div>
{{end}}
{{end}}
</div>
{{end}}
</div>
{{else}}
<p class="text-gray-500 dark:text-gray-400 mb-4">No AC units yet.</p>
{{end}}
<form method="POST" action="/setup/ac/add" class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
<h3 class="font-medium mb-3">Add AC Unit</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Name</label>
<input type="text" name="name" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Type</label>
<select name="ac_type" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
<option value="portable">Portable</option>
<option value="split">Split</option>
<option value="window">Window</option>
<option value="central">Central</option>
</select>
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Capacity (BTU/h)</label>
<input type="number" step="any" name="capacity_btu" value="12000" required class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
<div>
<label class="block text-sm text-gray-600 dark:text-gray-400 mb-1">Efficiency (EER)</label>
<input type="number" step="any" name="efficiency_eer" value="10.0" class="w-full px-3 py-2 border dark:border-gray-600 rounded bg-white dark:bg-gray-700">
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 pb-2">
<input type="checkbox" name="has_dehumidify" value="true" class="rounded border-gray-300 dark:border-gray-600">
<span class="text-sm text-gray-600 dark:text-gray-400">Has Dehumidify</span>
</label>
</div>
</div>
<button type="submit" class="mt-3 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Add AC Unit</button>
</form>
{{end}}
</section>
{{end}}
{{define "toggles"}}
<section id="toggles" class="mb-8">
<h2 class="text-xl font-semibold mb-3">Scenario Toggles</h2>
{{if not .Profile}}
<p class="text-gray-500 dark:text-gray-400">Create a profile first.</p>
{{else}}
<div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
{{range $name, $active := .Toggles}}
<form method="POST" action="/setup/toggles/set" class="flex items-center justify-between p-2 rounded border dark:border-gray-600">
<input type="hidden" name="name" value="{{$name}}">
<input type="hidden" name="active" value="{{if $active}}false{{else}}true{{end}}">
<span class="font-medium">{{$name}}</span>
<button type="submit" class="px-3 py-1 rounded text-sm text-white {{if $active}}bg-green-600 hover:bg-red-600{{else}}bg-gray-400 hover:bg-green-600{{end}}">
{{if $active}}ON{{else}}OFF{{end}}
</button>
</form>
{{end}}
<form method="POST" action="/setup/toggles/set" class="flex items-center gap-2 p-2 rounded border dark:border-gray-600">
<input type="text" name="name" placeholder="new toggle" required class="flex-1 px-2 py-1 border dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-sm">
<input type="hidden" name="active" value="true">
<button type="submit" class="px-3 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700">Add</button>
</form>
</div>
</div>
{{end}}
</section>
{{end}}
{{define "forecast"}}
<section id="forecast" class="mb-8">
<h2 class="text-xl font-semibold mb-3">Forecast</h2>
{{if not .Profile}}
<p class="text-gray-500 dark:text-gray-400">Create a profile first.</p>
{{else}}
<div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
Last fetched: <strong>{{if .LastFetch}}{{.LastFetch}}{{else}}never{{end}}</strong>
</p>
<form method="POST" action="/setup/forecast/fetch">
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Fetch Forecast Now</button>
</form>
</div>
{{end}}
</section>
{{end}}

34
internal/cli/toggle.go Normal file
View File

@@ -0,0 +1,34 @@
package cli
import (
"fmt"
"github.com/spf13/cobra"
)
func init() {
toggleCmd := &cobra.Command{
Use: "toggle <scenario>",
Short: "Toggle a scenario (gaming, cooking, ac-off)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
p, err := getActiveProfile()
if err != nil {
return err
}
name := args[0]
active, _ := db.GetActiveToggleNames(p.ID)
newState := !active[name]
if err := db.SetToggle(p.ID, name, newState); err != nil {
return err
}
state := "OFF"
if newState {
state = "ON"
}
fmt.Printf("Toggle %q: %s\n", name, state)
return nil
},
}
rootCmd.AddCommand(toggleCmd)
}

20
internal/cli/version.go Normal file
View File

@@ -0,0 +1,20 @@
package cli
import (
"fmt"
"github.com/spf13/cobra"
)
const version = "0.1.0"
func init() {
versionCmd := &cobra.Command{
Use: "version",
Short: "Print version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("heatwave-autopilot v%s\n", version)
},
}
rootCmd.AddCommand(versionCmd)
}

57
internal/cli/web.go Normal file
View File

@@ -0,0 +1,57 @@
package cli
import (
"fmt"
"net"
"net/http"
"time"
"github.com/spf13/cobra"
)
var (
webPort int
webDate string
webOpen bool
)
func init() {
webCmd := &cobra.Command{
Use: "web",
Short: "Start local web server with the heat dashboard",
Long: "Generates the heat report and serves it via a local HTTP server. Shortcut for 'heatwave report serve'.",
RunE: func(cmd *cobra.Command, args []string) error {
dateStr := webDate
if dateStr == "" {
dateStr = time.Now().Format("2006-01-02")
}
return runWebServer(dateStr, webPort, webOpen)
},
}
webCmd.Flags().IntVar(&webPort, "port", 8080, "HTTP port to serve on")
webCmd.Flags().StringVar(&webDate, "date", "", "date (YYYY-MM-DD, default: today)")
webCmd.Flags().BoolVar(&webOpen, "open", true, "open browser automatically")
rootCmd.AddCommand(webCmd)
}
// runWebServer starts the web server with dashboard + setup UI.
func runWebServer(dateStr string, port int, open bool) error {
mux := http.NewServeMux()
registerSetupRoutes(mux, dateStr)
addr := fmt.Sprintf(":%d", port)
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen %s: %w", addr, err)
}
url := fmt.Sprintf("http://localhost:%d", port)
fmt.Printf("Serving heat dashboard at %s (Ctrl+C to stop)\n", url)
if open {
openBrowser(url)
}
return http.Serve(ln, mux)
}

View File

@@ -0,0 +1,473 @@
package cli
import (
_ "embed"
"fmt"
"html/template"
"net/http"
"strconv"
"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 },
}
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
}
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")
}
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, `<!DOCTYPE html><html><head><style>%s</style></head>
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<nav class="bg-white dark:bg-gray-800 shadow mb-4">
<div class="container mx-auto flex items-center gap-6 px-4 py-3">
<span class="font-bold text-lg">Heatwave</span>
<a href="/" class="font-medium text-blue-600 dark:text-blue-400 underline">Dashboard</a>
<a href="/setup" class="text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Setup</a>
</div>
</nav>
<div class="container mx-auto py-4 px-4">
<h1 class="text-2xl font-bold mb-4">Dashboard</h1>
<div class="p-4 bg-yellow-50 dark:bg-yellow-950 border-l-4 border-yellow-400 rounded">
<p class="font-medium">Cannot load dashboard</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">%s</p>
<p class="mt-3"><a href="/setup" class="text-blue-600 dark:text-blue-400 underline">Go to Setup</a> to configure your profile and fetch forecast data.</p>
</div>
</div></body></html>`, 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)
}
// --- 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)
}

62
internal/config/config.go Normal file
View File

@@ -0,0 +1,62 @@
package config
import (
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// Config holds the application configuration.
type Config struct {
LLM LLMConfig `yaml:"llm"`
}
// LLMConfig holds LLM provider settings.
type LLMConfig struct {
Provider string `yaml:"provider"` // anthropic, openai, ollama, none
Model string `yaml:"model"`
Endpoint string `yaml:"endpoint"` // for ollama
}
// DefaultConfig returns a Config with sensible defaults.
func DefaultConfig() Config {
return Config{
LLM: LLMConfig{Provider: "none"},
}
}
// ConfigDir returns the XDG config directory for heatwave.
func ConfigDir() string {
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "heatwave")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "heatwave")
}
// DataDir returns the XDG data directory for heatwave.
func DataDir() string {
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return filepath.Join(xdg, "heatwave")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".local", "share", "heatwave")
}
// DefaultDBPath returns the default SQLite database path.
func DefaultDBPath() string {
return filepath.Join(DataDir(), "heatwave.db")
}
// Load reads the config file from the config directory.
func Load() Config {
cfg := DefaultConfig()
path := filepath.Join(ConfigDir(), "config.yaml")
data, err := os.ReadFile(path)
if err != nil {
return cfg
}
_ = yaml.Unmarshal(data, &cfg)
return cfg
}

78
internal/heat/budget.go Normal file
View File

@@ -0,0 +1,78 @@
package heat
// BudgetStatus represents the thermal comfort state of a room.
type BudgetStatus int
const (
Comfortable BudgetStatus = iota // headroom > 20% of AC capacity
Marginal // headroom 020% of AC capacity
Overloaded // headroom < 0 (AC can't keep up)
)
func (s BudgetStatus) String() string {
switch s {
case Comfortable:
return "comfortable"
case Marginal:
return "marginal"
case Overloaded:
return "overloaded"
default:
return "unknown"
}
}
// BudgetInput holds all inputs for a room heat budget calculation.
type BudgetInput struct {
Devices []Device
DeviceMode DeviceMode
Occupants []Occupant
Solar SolarParams
Ventilation VentilationParams
ACCapacityBTUH float64
}
// BudgetResult holds the computed heat budget for a room.
type BudgetResult struct {
InternalGainsW float64
SolarGainW float64
VentilationGainW float64
TotalGainW float64
TotalGainBTUH float64
ACCapacityBTUH float64
HeadroomBTUH float64
Status BudgetStatus
}
// ComputeRoomBudget calculates the full heat budget for a room.
func ComputeRoomBudget(in BudgetInput) BudgetResult {
internal := TotalInternalGains(in.Devices, in.DeviceMode, in.Occupants)
solar := SolarGain(in.Solar)
ventilation := VentilationGain(in.Ventilation)
totalW := internal + solar + ventilation
totalBTUH := WattsToBTUH(totalW)
headroom := in.ACCapacityBTUH - totalBTUH
status := Overloaded
if in.ACCapacityBTUH > 0 {
ratio := headroom / in.ACCapacityBTUH
switch {
case ratio > 0.2:
status = Comfortable
case ratio >= 0:
status = Marginal
}
}
return BudgetResult{
InternalGainsW: internal,
SolarGainW: solar,
VentilationGainW: ventilation,
TotalGainW: totalW,
TotalGainBTUH: totalBTUH,
ACCapacityBTUH: in.ACCapacityBTUH,
HeadroomBTUH: headroom,
Status: status,
}
}

View File

@@ -0,0 +1,129 @@
package heat
import (
"testing"
)
func TestComputeRoomBudget(t *testing.T) {
input := BudgetInput{
Devices: []Device{
{WattsIdle: 65, WattsTypical: 200, WattsPeak: 450, DutyCycle: 1.0},
},
DeviceMode: ModeTypical,
Occupants: []Occupant{
{Count: 1, Activity: Sedentary},
},
Solar: SolarParams{
AreaSqm: 15,
WindowFraction: 0.15,
SHGC: 0.6,
ShadingFactor: 1.0,
OrientationFactor: 0.8,
CloudFactor: 0.9,
SunshineFraction: 0.8,
PeakIrradiance: 800,
},
Ventilation: VentilationParams{
ACH: 1.0,
VolumeCubicM: 45,
OutdoorTempC: 35,
IndoorTempC: 25,
RhoCp: 1.2,
},
ACCapacityBTUH: 8000,
}
result := ComputeRoomBudget(input)
// Internal: 200 (device) + 100 (occupant) = 300W
if !almostEqual(result.InternalGainsW, 300, tolerance) {
t.Errorf("InternalGainsW = %v, want 300", result.InternalGainsW)
}
// Solar: 800 * 0.8 * (15*0.15) * 0.6 * 1.0 * 0.9 * 0.8 = 800*0.8*2.25*0.6*0.9*0.8 = 622.08
if !almostEqual(result.SolarGainW, 622.08, 0.1) {
t.Errorf("SolarGainW = %v, want ~622.08", result.SolarGainW)
}
// Ventilation: 1 * 45 * 1200 * 10 / 3600 = 150W
if !almostEqual(result.VentilationGainW, 150, tolerance) {
t.Errorf("VentilationGainW = %v, want 150", result.VentilationGainW)
}
// Total gain = 300 + 622.08 + 150 = 1072.08W
expectedTotal := 300 + 622.08 + 150.0
if !almostEqual(result.TotalGainW, expectedTotal, 0.1) {
t.Errorf("TotalGainW = %v, want %v", result.TotalGainW, expectedTotal)
}
// TotalGainBTUH
expectedBTUH := WattsToBTUH(expectedTotal)
if !almostEqual(result.TotalGainBTUH, expectedBTUH, 1) {
t.Errorf("TotalGainBTUH = %v, want %v", result.TotalGainBTUH, expectedBTUH)
}
// Headroom = 8000 - totalGainBTUH
expectedHeadroom := 8000 - expectedBTUH
if !almostEqual(result.HeadroomBTUH, expectedHeadroom, 1) {
t.Errorf("HeadroomBTUH = %v, want %v", result.HeadroomBTUH, expectedHeadroom)
}
// Status should be comfortable (headroom > 20% of 8000 = 1600)
if result.Status != Comfortable {
t.Errorf("Status = %v, want Comfortable", result.Status)
}
}
func TestBudgetStatus(t *testing.T) {
tests := []struct {
name string
totalGainW float64
acBTUH float64
want BudgetStatus
}{
{
name: "comfortable: headroom > 20% of AC",
totalGainW: 500,
acBTUH: 8000,
want: Comfortable,
},
{
name: "marginal: headroom 0-20% of AC",
totalGainW: 2000,
acBTUH: 8000,
want: Marginal,
},
{
name: "overloaded: negative headroom",
totalGainW: 3000,
acBTUH: 8000,
want: Overloaded,
},
{
name: "no AC at all",
totalGainW: 500,
acBTUH: 0,
want: Overloaded,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := BudgetInput{
Devices: nil,
DeviceMode: ModeIdle,
Occupants: nil,
Solar: SolarParams{},
Ventilation: VentilationParams{RhoCp: 1.2},
ACCapacityBTUH: tt.acBTUH,
}
// Manually set gains via devices to control the total
input.Devices = []Device{
{WattsIdle: tt.totalGainW, WattsTypical: tt.totalGainW, WattsPeak: tt.totalGainW, DutyCycle: 1.0},
}
result := ComputeRoomBudget(input)
if result.Status != tt.want {
t.Errorf("Status = %v, want %v (headroom=%v)", result.Status, tt.want, result.HeadroomBTUH)
}
})
}
}

View File

@@ -0,0 +1,103 @@
package heat
// SolarParams holds inputs for simplified solar gain calculation.
type SolarParams struct {
AreaSqm float64 // room floor area in m²
WindowFraction float64 // fraction of wall area that is window (e.g., 0.15)
SHGC float64 // solar heat gain coefficient of glazing (e.g., 0.6)
ShadingFactor float64 // 0.0 (fully shaded) to 1.0 (no shading)
OrientationFactor float64 // 0.01.0, varies by hour and wall orientation
CloudFactor float64 // 0.0 (overcast) to 1.0 (clear)
SunshineFraction float64 // fraction of hour with sunshine (0.01.0)
PeakIrradiance float64 // W/m² on the surface (e.g., 800 for direct sun)
}
// OrientationFactor returns a solar exposure factor (0.01.0) based on
// wall orientation and hour of day. This is a simplified proxy.
func OrientationFactor(orientation string, hour int) float64 {
switch orientation {
case "S":
if hour >= 10 && hour <= 16 {
return 1.0
}
if hour >= 8 && hour < 10 || hour > 16 && hour <= 18 {
return 0.5
}
return 0.0
case "E":
if hour >= 6 && hour <= 11 {
return 0.9
}
if hour > 11 && hour <= 14 {
return 0.3
}
return 0.0
case "W":
if hour >= 14 && hour <= 20 {
return 0.9
}
if hour >= 11 && hour < 14 {
return 0.3
}
return 0.0
case "SE":
if hour >= 7 && hour <= 13 {
return 0.9
}
if hour > 13 && hour <= 16 {
return 0.4
}
return 0.0
case "SW":
if hour >= 12 && hour <= 19 {
return 0.9
}
if hour >= 9 && hour < 12 {
return 0.4
}
return 0.0
case "NE":
if hour >= 5 && hour <= 9 {
return 0.5
}
return 0.1
case "NW":
if hour >= 17 && hour <= 21 {
return 0.5
}
return 0.1
case "N":
return 0.1 // minimal direct sun
default:
return 0.5
}
}
// SolarGain returns estimated solar heat gain in watts.
// Formula: irradiance * orientationFactor * windowArea * SHGC * shadingFactor * cloudFactor * sunshineFraction
func SolarGain(p SolarParams) float64 {
windowArea := p.AreaSqm * p.WindowFraction
return p.PeakIrradiance * p.OrientationFactor * windowArea * p.SHGC * p.ShadingFactor * p.CloudFactor * p.SunshineFraction
}
// DefaultRhoCp is the volumetric heat capacity of air in J/(m³·K).
// Approximately 1200 J/(m³·K) at sea level.
const DefaultRhoCp = 1200.0
// VentilationParams holds inputs for ventilation heat gain/loss calculation.
type VentilationParams struct {
ACH float64 // air changes per hour
VolumeCubicM float64 // room volume in m³
OutdoorTempC float64 // outdoor temperature in °C
IndoorTempC float64 // indoor temperature in °C
RhoCp float64 // volumetric heat capacity in kJ/(m³·K); use value such that kJ * 1000 = J, or pass as J directly
}
// VentilationGain returns ventilation heat gain in watts.
// Positive = heating (outdoor hotter), negative = cooling (outdoor cooler).
// Formula: ACH * volume * rhoCp_J * deltaT / 3600
func VentilationGain(p VentilationParams) float64 {
rhoCpJ := p.RhoCp * 1000 // convert kJ/(m³·K) to J/(m³·K)
deltaT := p.OutdoorTempC - p.IndoorTempC
return p.ACH * p.VolumeCubicM * rhoCpJ * deltaT / 3600
}

View File

@@ -0,0 +1,130 @@
package heat
import (
"testing"
)
func TestSolarGain(t *testing.T) {
tests := []struct {
name string
p SolarParams
want float64
}{
{
name: "midday south-facing room, clear sky, no shading",
p: SolarParams{
AreaSqm: 20,
WindowFraction: 0.15,
SHGC: 0.6,
ShadingFactor: 1.0,
OrientationFactor: 1.0,
CloudFactor: 1.0,
SunshineFraction: 1.0,
PeakIrradiance: 800,
},
// 800 * 1.0 * (20*0.15) * 0.6 * 1.0 * 1.0 * 1.0 = 800 * 3 * 0.6 = 1440
want: 1440,
},
{
name: "overcast, shutters closed",
p: SolarParams{
AreaSqm: 20,
WindowFraction: 0.15,
SHGC: 0.6,
ShadingFactor: 0.2,
OrientationFactor: 1.0,
CloudFactor: 0.3,
SunshineFraction: 0.2,
PeakIrradiance: 800,
},
// 800 * 1.0 * 3 * 0.6 * 0.2 * 0.3 * 0.2 = 1440 * 0.012 = 17.28
want: 17.28,
},
{
name: "night time (zero irradiance)",
p: SolarParams{
AreaSqm: 20,
WindowFraction: 0.15,
SHGC: 0.6,
ShadingFactor: 1.0,
OrientationFactor: 1.0,
CloudFactor: 1.0,
SunshineFraction: 0.0,
PeakIrradiance: 0,
},
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := SolarGain(tt.p)
if !almostEqual(got, tt.want, tolerance) {
t.Errorf("SolarGain() = %v, want %v", got, tt.want)
}
})
}
}
func TestVentilationGain(t *testing.T) {
tests := []struct {
name string
p VentilationParams
want float64
}{
{
name: "outdoor hotter than indoor, windows open",
p: VentilationParams{
ACH: 2.0,
VolumeCubicM: 45, // 15sqm * 3m ceiling
OutdoorTempC: 35,
IndoorTempC: 25,
RhoCp: 1.2, // kg/m³ * kJ/(kg·K) → ~1.2 kJ/(m³·K) = 1200 J/(m³·K)
},
// ACH * vol * rhoCp * deltaT / 3600
// 2 * 45 * 1200 * 10 / 3600 = 300
want: 300,
},
{
name: "outdoor cooler than indoor (negative gain = cooling)",
p: VentilationParams{
ACH: 2.0,
VolumeCubicM: 45,
OutdoorTempC: 18,
IndoorTempC: 25,
RhoCp: 1.2,
},
// 2 * 45 * 1200 * (-7) / 3600 = -210
want: -210,
},
{
name: "equal temperatures",
p: VentilationParams{
ACH: 2.0,
VolumeCubicM: 45,
OutdoorTempC: 25,
IndoorTempC: 25,
RhoCp: 1.2,
},
want: 0,
},
{
name: "windows closed (ACH=0)",
p: VentilationParams{
ACH: 0,
VolumeCubicM: 45,
OutdoorTempC: 35,
IndoorTempC: 25,
RhoCp: 1.2,
},
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := VentilationGain(tt.p)
if !almostEqual(got, tt.want, tolerance) {
t.Errorf("VentilationGain() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,93 @@
package heat
// DeviceMode selects which power draw to use for heat gain calculation.
type DeviceMode int
const (
ModeIdle DeviceMode = iota
ModeTypical
ModePeak
)
// Device represents a heat-producing device in a room.
type Device struct {
WattsIdle float64
WattsTypical float64
WattsPeak float64
DutyCycle float64 // 0.01.0
}
// ActivityLevel represents metabolic activity of room occupants.
type ActivityLevel int
const (
Sleeping ActivityLevel = iota
Sedentary
LightActivity
ModerateActivity
HeavyActivity
)
// metabolicWatts maps activity level to per-person heat output in watts.
var metabolicWatts = map[ActivityLevel]float64{
Sleeping: 70,
Sedentary: 100,
LightActivity: 130,
ModerateActivity: 200,
HeavyActivity: 300,
}
// Occupant represents people in a room.
type Occupant struct {
Count int
Activity ActivityLevel
}
// ParseActivityLevel converts a string to ActivityLevel.
func ParseActivityLevel(s string) ActivityLevel {
switch s {
case "sleeping":
return Sleeping
case "sedentary":
return Sedentary
case "light":
return LightActivity
case "moderate":
return ModerateActivity
case "heavy":
return HeavyActivity
default:
return Sedentary
}
}
// DeviceHeatGain returns heat output in watts for a device in the given mode.
func DeviceHeatGain(d Device, mode DeviceMode) float64 {
var base float64
switch mode {
case ModeIdle:
base = d.WattsIdle
case ModeTypical:
base = d.WattsTypical
case ModePeak:
base = d.WattsPeak
}
return base * d.DutyCycle
}
// OccupantHeatGain returns total metabolic heat output in watts.
func OccupantHeatGain(count int, activity ActivityLevel) float64 {
return float64(count) * metabolicWatts[activity]
}
// TotalInternalGains sums device and occupant heat gains in watts.
func TotalInternalGains(devices []Device, mode DeviceMode, occupants []Occupant) float64 {
var total float64
for _, d := range devices {
total += DeviceHeatGain(d, mode)
}
for _, o := range occupants {
total += OccupantHeatGain(o.Count, o.Activity)
}
return total
}

View File

@@ -0,0 +1,96 @@
package heat
import (
"testing"
)
func TestDeviceHeatGain(t *testing.T) {
tests := []struct {
name string
dev Device
mode DeviceMode
want float64
}{
{
name: "idle mode uses idle watts",
dev: Device{WattsIdle: 65, WattsTypical: 200, WattsPeak: 450, DutyCycle: 1.0},
mode: ModeIdle,
want: 65,
},
{
name: "typical mode with full duty cycle",
dev: Device{WattsIdle: 65, WattsTypical: 200, WattsPeak: 450, DutyCycle: 1.0},
mode: ModeTypical,
want: 200,
},
{
name: "typical mode with 50% duty cycle",
dev: Device{WattsIdle: 65, WattsTypical: 200, WattsPeak: 450, DutyCycle: 0.5},
mode: ModeTypical,
want: 100,
},
{
name: "peak mode",
dev: Device{WattsIdle: 65, WattsTypical: 200, WattsPeak: 450, DutyCycle: 1.0},
mode: ModePeak,
want: 450,
},
{
name: "zero duty cycle",
dev: Device{WattsIdle: 65, WattsTypical: 200, WattsPeak: 450, DutyCycle: 0.0},
mode: ModeTypical,
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := DeviceHeatGain(tt.dev, tt.mode)
if !almostEqual(got, tt.want, tolerance) {
t.Errorf("DeviceHeatGain() = %v, want %v", got, tt.want)
}
})
}
}
func TestOccupantHeatGain(t *testing.T) {
tests := []struct {
name string
count int
activity ActivityLevel
want float64
}{
{"one sleeping person", 1, Sleeping, 70},
{"one sedentary person", 1, Sedentary, 100},
{"two sedentary people", 2, Sedentary, 200},
{"one light activity", 1, LightActivity, 130},
{"one moderate activity", 1, ModerateActivity, 200},
{"one heavy activity", 1, HeavyActivity, 300},
{"zero people", 0, Sedentary, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := OccupantHeatGain(tt.count, tt.activity)
if !almostEqual(got, tt.want, tolerance) {
t.Errorf("OccupantHeatGain(%d, %v) = %v, want %v", tt.count, tt.activity, got, tt.want)
}
})
}
}
func TestTotalInternalGains(t *testing.T) {
devices := []Device{
{WattsIdle: 65, WattsTypical: 200, WattsPeak: 450, DutyCycle: 1.0},
{WattsIdle: 30, WattsTypical: 80, WattsPeak: 120, DutyCycle: 0.5},
}
occupants := []Occupant{
{Count: 1, Activity: Sedentary},
{Count: 2, Activity: LightActivity},
}
// 200 + 40 + 100 + 260 = 600
got := TotalInternalGains(devices, ModeTypical, occupants)
want := 600.0
if !almostEqual(got, want, tolerance) {
t.Errorf("TotalInternalGains() = %v, want %v", got, want)
}
}

14
internal/heat/units.go Normal file
View File

@@ -0,0 +1,14 @@
package heat
// 1 Watt = 3.41214 BTU/h
const wattToBTUH = 3.41214
// WattsToBTUH converts watts to BTU per hour.
func WattsToBTUH(watts float64) float64 {
return watts * wattToBTUH
}
// BTUHToWatts converts BTU per hour to watts.
func BTUHToWatts(btuh float64) float64 {
return btuh / wattToBTUH
}

View File

@@ -0,0 +1,62 @@
package heat
import (
"math"
"testing"
)
const tolerance = 0.01
func almostEqual(a, b, tol float64) bool {
return math.Abs(a-b) < tol
}
func TestWattsToBTUH(t *testing.T) {
tests := []struct {
name string
watts float64
want float64
}{
{"zero", 0, 0},
{"one watt", 1, 3.41214},
{"1000 watts", 1000, 3412.14},
{"negative", -100, -341.214},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := WattsToBTUH(tt.watts)
if !almostEqual(got, tt.want, tolerance) {
t.Errorf("WattsToBTUH(%v) = %v, want %v", tt.watts, got, tt.want)
}
})
}
}
func TestBTUHToWatts(t *testing.T) {
tests := []struct {
name string
btuh float64
want float64
}{
{"zero", 0, 0},
{"one BTU/h", 1, 0.29307},
{"8000 BTU/h", 8000, 2344.57},
{"negative", -3412.14, -1000.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := BTUHToWatts(tt.btuh)
if !almostEqual(got, tt.want, tolerance) {
t.Errorf("BTUHToWatts(%v) = %v, want %v", tt.btuh, got, tt.want)
}
})
}
}
func TestRoundTrip(t *testing.T) {
original := 500.0
got := BTUHToWatts(WattsToBTUH(original))
if !almostEqual(got, original, tolerance) {
t.Errorf("round-trip failed: %v -> %v", original, got)
}
}

106
internal/llm/anthropic.go Normal file
View File

@@ -0,0 +1,106 @@
package llm
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// Anthropic implements Provider using the Anthropic Messages API.
type Anthropic struct {
apiKey string
model string
client *http.Client
baseURL string
}
// NewAnthropic creates a new Anthropic provider.
func NewAnthropic(apiKey, model string, client *http.Client) *Anthropic {
if client == nil {
client = &http.Client{Timeout: 60 * time.Second}
}
if model == "" {
model = "claude-sonnet-4-5-20250929"
}
return &Anthropic{apiKey: apiKey, model: model, client: client, baseURL: "https://api.anthropic.com"}
}
func (a *Anthropic) Name() string { return "anthropic" }
type anthropicRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System string `json:"system"`
Messages []anthropicMessage `json:"messages"`
}
type anthropicMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type anthropicResponse struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
func (a *Anthropic) call(ctx context.Context, systemPrompt, userMessage string, maxTokens int) (string, error) {
reqBody := anthropicRequest{
Model: a.model,
MaxTokens: maxTokens,
System: systemPrompt,
Messages: []anthropicMessage{
{Role: "user", Content: userMessage},
},
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+"/v1/messages", strings.NewReader(string(body)))
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", a.apiKey)
req.Header.Set("anthropic-version", "2023-06-01")
resp, err := a.client.Do(req)
if err != nil {
return "", fmt.Errorf("anthropic call: %w", err)
}
defer resp.Body.Close()
var result anthropicResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode response: %w", err)
}
if result.Error != nil {
return "", fmt.Errorf("anthropic error: %s", result.Error.Message)
}
if len(result.Content) == 0 {
return "", fmt.Errorf("empty response from anthropic")
}
return result.Content[0].Text, nil
}
func (a *Anthropic) Summarize(ctx context.Context, input SummaryInput) (string, error) {
return a.call(ctx, SummarizeSystemPrompt(), BuildSummaryPrompt(input), 300)
}
func (a *Anthropic) RewriteAction(ctx context.Context, input ActionInput) (string, error) {
return a.call(ctx, RewriteActionSystemPrompt(), BuildRewriteActionPrompt(input), 100)
}
func (a *Anthropic) GenerateHeatPlan(ctx context.Context, input HeatPlanInput) (string, error) {
return a.call(ctx, HeatPlanSystemPrompt(), BuildHeatPlanPrompt(input), 2000)
}

121
internal/llm/llm_test.go Normal file
View File

@@ -0,0 +1,121 @@
package llm
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestNoopProvider(t *testing.T) {
n := NewNoop()
if n.Name() != "none" {
t.Errorf("Name = %s, want none", n.Name())
}
s, err := n.Summarize(context.Background(), SummaryInput{})
if err != nil || s != "" {
t.Errorf("Summarize = (%q, %v), want empty", s, err)
}
r, err := n.RewriteAction(context.Background(), ActionInput{})
if err != nil || r != "" {
t.Errorf("RewriteAction = (%q, %v), want empty", r, err)
}
h, err := n.GenerateHeatPlan(context.Background(), HeatPlanInput{})
if err != nil || h != "" {
t.Errorf("GenerateHeatPlan = (%q, %v), want empty", h, err)
}
}
func TestAnthropicProvider_MockServer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("x-api-key") != "test-key" {
t.Error("missing api key header")
}
if r.Header.Get("anthropic-version") != "2023-06-01" {
t.Error("missing anthropic-version header")
}
json.NewEncoder(w).Encode(map[string]any{
"content": []map[string]string{
{"type": "text", "text": "- Bullet one\n- Bullet two\n- Bullet three"},
},
})
}))
defer srv.Close()
a := NewAnthropic("test-key", "test-model", srv.Client())
a.baseURL = srv.URL
result, err := a.Summarize(context.Background(), testSummaryInput())
if err != nil {
t.Fatalf("Summarize: %v", err)
}
if result == "" {
t.Error("empty result")
}
}
func TestAnthropicProvider_Error(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"error": map[string]string{"message": "invalid api key"},
})
}))
defer srv.Close()
a := NewAnthropic("bad-key", "", srv.Client())
a.baseURL = srv.URL
_, err := a.Summarize(context.Background(), SummaryInput{})
if err == nil {
t.Error("expected error")
}
}
func TestOpenAIProvider_MockServer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !containsBearer(r.Header.Get("Authorization")) {
t.Error("missing bearer token")
}
json.NewEncoder(w).Encode(map[string]any{
"choices": []map[string]any{
{"message": map[string]string{"content": "Test summary"}},
},
})
}))
defer srv.Close()
o := NewOpenAI("test-key", "test-model", srv.Client())
o.baseURL = srv.URL
result, err := o.Summarize(context.Background(), testSummaryInput())
if err != nil {
t.Fatalf("Summarize: %v", err)
}
if result != "Test summary" {
t.Errorf("result = %q, want 'Test summary'", result)
}
}
func TestOllamaProvider_MockServer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"message": map[string]string{"content": "Ollama summary"},
})
}))
defer srv.Close()
o := NewOllama("test-model", srv.URL, srv.Client())
result, err := o.Summarize(context.Background(), testSummaryInput())
if err != nil {
t.Fatalf("Summarize: %v", err)
}
if result != "Ollama summary" {
t.Errorf("result = %q, want 'Ollama summary'", result)
}
}
func containsBearer(s string) bool {
return len(s) > 7 && s[:7] == "Bearer "
}

12
internal/llm/noop.go Normal file
View File

@@ -0,0 +1,12 @@
package llm
import "context"
// Noop is a no-op LLM provider that returns empty strings.
type Noop struct{}
func NewNoop() *Noop { return &Noop{} }
func (n *Noop) Name() string { return "none" }
func (n *Noop) Summarize(_ context.Context, _ SummaryInput) (string, error) { return "", nil }
func (n *Noop) RewriteAction(_ context.Context, _ ActionInput) (string, error) { return "", nil }
func (n *Noop) GenerateHeatPlan(_ context.Context, _ HeatPlanInput) (string, error) { return "", nil }

99
internal/llm/ollama.go Normal file
View File

@@ -0,0 +1,99 @@
package llm
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// Ollama implements Provider using a local Ollama instance.
type Ollama struct {
model string
endpoint string
client *http.Client
}
// NewOllama creates a new Ollama provider.
func NewOllama(model, endpoint string, client *http.Client) *Ollama {
if client == nil {
client = &http.Client{Timeout: 120 * time.Second}
}
if model == "" {
model = "llama3.2"
}
if endpoint == "" {
endpoint = "http://localhost:11434"
}
return &Ollama{model: model, endpoint: endpoint, client: client}
}
func (o *Ollama) Name() string { return "ollama" }
type ollamaRequest struct {
Model string `json:"model"`
Messages []ollamaMessage `json:"messages"`
Stream bool `json:"stream"`
}
type ollamaMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type ollamaResponse struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
Error string `json:"error"`
}
func (o *Ollama) call(ctx context.Context, systemPrompt, userMessage string) (string, error) {
reqBody := ollamaRequest{
Model: o.model,
Messages: []ollamaMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userMessage},
},
Stream: false,
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.endpoint+"/api/chat", strings.NewReader(string(body)))
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := o.client.Do(req)
if err != nil {
return "", fmt.Errorf("ollama call: %w", err)
}
defer resp.Body.Close()
var result ollamaResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode response: %w", err)
}
if result.Error != "" {
return "", fmt.Errorf("ollama error: %s", result.Error)
}
return result.Message.Content, nil
}
func (o *Ollama) Summarize(ctx context.Context, input SummaryInput) (string, error) {
return o.call(ctx, SummarizeSystemPrompt(), BuildSummaryPrompt(input))
}
func (o *Ollama) RewriteAction(ctx context.Context, input ActionInput) (string, error) {
return o.call(ctx, RewriteActionSystemPrompt(), BuildRewriteActionPrompt(input))
}
func (o *Ollama) GenerateHeatPlan(ctx context.Context, input HeatPlanInput) (string, error) {
return o.call(ctx, HeatPlanSystemPrompt(), BuildHeatPlanPrompt(input))
}

103
internal/llm/openai.go Normal file
View File

@@ -0,0 +1,103 @@
package llm
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// OpenAI implements Provider using the OpenAI Chat Completions API.
type OpenAI struct {
apiKey string
model string
client *http.Client
baseURL string
}
// NewOpenAI creates a new OpenAI provider.
func NewOpenAI(apiKey, model string, client *http.Client) *OpenAI {
if client == nil {
client = &http.Client{Timeout: 60 * time.Second}
}
if model == "" {
model = "gpt-4o"
}
return &OpenAI{apiKey: apiKey, model: model, client: client, baseURL: "https://api.openai.com"}
}
func (o *OpenAI) Name() string { return "openai" }
type openAIRequest struct {
Model string `json:"model"`
Messages []openAIMessage `json:"messages"`
}
type openAIMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type openAIResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
func (o *OpenAI) call(ctx context.Context, systemPrompt, userMessage string) (string, error) {
reqBody := openAIRequest{
Model: o.model,
Messages: []openAIMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userMessage},
},
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.baseURL+"/v1/chat/completions", strings.NewReader(string(body)))
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+o.apiKey)
resp, err := o.client.Do(req)
if err != nil {
return "", fmt.Errorf("openai call: %w", err)
}
defer resp.Body.Close()
var result openAIResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode response: %w", err)
}
if result.Error != nil {
return "", fmt.Errorf("openai error: %s", result.Error.Message)
}
if len(result.Choices) == 0 {
return "", fmt.Errorf("empty response from openai")
}
return result.Choices[0].Message.Content, nil
}
func (o *OpenAI) Summarize(ctx context.Context, input SummaryInput) (string, error) {
return o.call(ctx, SummarizeSystemPrompt(), BuildSummaryPrompt(input))
}
func (o *OpenAI) RewriteAction(ctx context.Context, input ActionInput) (string, error) {
return o.call(ctx, RewriteActionSystemPrompt(), BuildRewriteActionPrompt(input))
}
func (o *OpenAI) GenerateHeatPlan(ctx context.Context, input HeatPlanInput) (string, error) {
return o.call(ctx, HeatPlanSystemPrompt(), BuildHeatPlanPrompt(input))
}

98
internal/llm/prompt.go Normal file
View File

@@ -0,0 +1,98 @@
package llm
import (
"fmt"
"strings"
)
const summarizeSystemPrompt = `You are a heat preparedness assistant. You receive computed heat model data for a specific day.
Generate exactly 3 concise bullet points summarizing the key drivers and risks.
Rules:
- Reference ONLY the data provided below. Do not invent or assume additional information.
- Use preparedness language (comfort, planning). Never give medical advice or diagnoses.
- Each bullet: max 20 words, plain language, actionable insight.
- Format: "- [bullet text]" (markdown list)`
const rewriteActionSystemPrompt = `You are a heat preparedness assistant. Rewrite the given technical action into a clear, friendly, plain-language instruction.
Rules:
- One sentence, max 25 words.
- Reference the provided temperature and time context.
- Use preparedness language. Never give medical advice.
- Return only the rewritten sentence, nothing else.`
const heatPlanSystemPrompt = `You are a heat preparedness assistant. Generate a 1-page plain-language heat plan document.
Rules:
- Reference ONLY the data provided below. Do not invent information.
- Use preparedness language (comfort, planning). Never give medical advice or diagnoses.
- Structure: Brief overview (2-3 sentences), then hour-by-hour key actions, then care reminders.
- Use simple language anyone can understand.
- Keep total length under 500 words.
- Format as markdown with headers.`
// BuildSummaryPrompt constructs the user message for Summarize.
func BuildSummaryPrompt(input SummaryInput) string {
var b strings.Builder
fmt.Fprintf(&b, "Date: %s\n", input.Date)
fmt.Fprintf(&b, "Peak temperature: %.1f°C\n", input.PeakTempC)
fmt.Fprintf(&b, "Minimum night temperature: %.1f°C\n", input.MinNightTempC)
fmt.Fprintf(&b, "Overall risk level: %s\n", input.RiskLevel)
fmt.Fprintf(&b, "AC headroom: %.0f BTU/h\n", input.ACHeadroomBTUH)
fmt.Fprintf(&b, "Budget status: %s\n", input.BudgetStatus)
if len(input.TopHeatSources) > 0 {
b.WriteString("Top heat sources:\n")
for _, s := range input.TopHeatSources {
fmt.Fprintf(&b, " - %s: %.0fW\n", s.Name, s.Watts)
}
}
if len(input.ActiveWarnings) > 0 {
b.WriteString("Active warnings:\n")
for _, w := range input.ActiveWarnings {
fmt.Fprintf(&b, " - %s\n", w)
}
}
if len(input.RiskWindows) > 0 {
b.WriteString("Risk windows:\n")
for _, rw := range input.RiskWindows {
fmt.Fprintf(&b, " - %02d:00%02d:00, peak %.1f°C, level: %s\n", rw.StartHour, rw.EndHour, rw.PeakTempC, rw.Level)
}
}
return b.String()
}
// BuildRewriteActionPrompt constructs the user message for RewriteAction.
func BuildRewriteActionPrompt(input ActionInput) string {
return fmt.Sprintf("Action: %s\nDescription: %s\nCurrent temperature: %.1f°C\nHour: %02d:00",
input.ActionName, input.Description, input.TempC, input.Hour)
}
// BuildHeatPlanPrompt constructs the user message for GenerateHeatPlan.
func BuildHeatPlanPrompt(input HeatPlanInput) string {
var b strings.Builder
b.WriteString(BuildSummaryPrompt(input.Summary))
b.WriteString("\nTimeline:\n")
for _, s := range input.Timeline {
actions := "none"
if len(s.Actions) > 0 {
actions = strings.Join(s.Actions, ", ")
}
fmt.Fprintf(&b, " %02d:00 | %.1f°C | risk: %s | budget: %s | actions: %s\n",
s.Hour, s.TempC, s.RiskLevel, s.BudgetStatus, actions)
}
if len(input.CareChecklist) > 0 {
b.WriteString("\nCare checklist:\n")
for _, c := range input.CareChecklist {
fmt.Fprintf(&b, " - %s\n", c)
}
}
return b.String()
}
// SummarizeSystemPrompt returns the system prompt for Summarize.
func SummarizeSystemPrompt() string { return summarizeSystemPrompt }
// RewriteActionSystemPrompt returns the system prompt for RewriteAction.
func RewriteActionSystemPrompt() string { return rewriteActionSystemPrompt }
// HeatPlanSystemPrompt returns the system prompt for GenerateHeatPlan.
func HeatPlanSystemPrompt() string { return heatPlanSystemPrompt }

View File

@@ -0,0 +1,93 @@
package llm
import (
"strings"
"testing"
)
func testSummaryInput() SummaryInput {
return SummaryInput{
Date: "2025-07-15",
PeakTempC: 37.2,
MinNightTempC: 22.5,
RiskLevel: "high",
TopHeatSources: []HeatSource{{Name: "Gaming PC", Watts: 200}, {Name: "Monitor", Watts: 80}},
ACHeadroomBTUH: 4500,
BudgetStatus: "marginal",
ActiveWarnings: []string{"DWD: Amtliche WARNUNG vor HITZE"},
RiskWindows: []RiskWindowSummary{{StartHour: 11, EndHour: 18, PeakTempC: 37.2, Level: "high"}},
}
}
func TestBuildSummaryPrompt_ContainsAllFields(t *testing.T) {
p := BuildSummaryPrompt(testSummaryInput())
checks := []string{
"2025-07-15",
"37.2",
"22.5",
"high",
"Gaming PC",
"200W",
"4500 BTU/h",
"marginal",
"WARNUNG",
"11:00",
"18:00",
}
for _, c := range checks {
if !strings.Contains(p, c) {
t.Errorf("prompt missing %q", c)
}
}
}
func TestBuildRewriteActionPrompt(t *testing.T) {
p := BuildRewriteActionPrompt(ActionInput{
ActionName: "Close south-facing shutters",
Description: "Block direct sun",
TempC: 34.5,
Hour: 9,
})
if !strings.Contains(p, "Close south-facing shutters") {
t.Error("missing action name")
}
if !strings.Contains(p, "34.5") {
t.Error("missing temperature")
}
if !strings.Contains(p, "09:00") {
t.Error("missing hour")
}
}
func TestBuildHeatPlanPrompt(t *testing.T) {
input := HeatPlanInput{
Summary: testSummaryInput(),
Timeline: []TimelineSlotSummary{
{Hour: 12, TempC: 35, RiskLevel: "high", BudgetStatus: "marginal", Actions: []string{"Hydration"}},
},
CareChecklist: []string{"Check elderly occupants at 14:00"},
}
p := BuildHeatPlanPrompt(input)
if !strings.Contains(p, "Timeline:") {
t.Error("missing timeline section")
}
if !strings.Contains(p, "Hydration") {
t.Error("missing actions")
}
if !strings.Contains(p, "Care checklist:") {
t.Error("missing care checklist")
}
}
func TestSystemPrompts_NotEmpty(t *testing.T) {
if SummarizeSystemPrompt() == "" {
t.Error("empty summarize system prompt")
}
if RewriteActionSystemPrompt() == "" {
t.Error("empty rewrite system prompt")
}
if HeatPlanSystemPrompt() == "" {
t.Error("empty heatplan system prompt")
}
}

71
internal/llm/provider.go Normal file
View File

@@ -0,0 +1,71 @@
package llm
import "context"
// Provider is the interface for LLM backends.
type Provider interface {
Summarize(ctx context.Context, input SummaryInput) (string, error)
RewriteAction(ctx context.Context, action ActionInput) (string, error)
GenerateHeatPlan(ctx context.Context, input HeatPlanInput) (string, error)
Name() string
}
// HeatSource represents a ranked heat source for summary.
type HeatSource struct {
Name string
Watts float64
}
// RiskWindowSummary is a simplified risk window for LLM input.
type RiskWindowSummary struct {
StartHour int
EndHour int
PeakTempC float64
Level string
}
// SummaryInput holds computed data for the 3-bullet summary.
type SummaryInput struct {
Date string
PeakTempC float64
MinNightTempC float64
RiskLevel string
TopHeatSources []HeatSource
ACHeadroomBTUH float64
BudgetStatus string
ActiveWarnings []string
RiskWindows []RiskWindowSummary
}
// ActionInput holds data for rewriting a technical action.
type ActionInput struct {
ActionName string
Description string
TempC float64
Hour int
}
// TimelineSlotSummary is a simplified timeline slot for LLM input.
type TimelineSlotSummary struct {
Hour int
TempC float64
RiskLevel string
BudgetStatus string
Actions []string
}
// ActionSummary is a simplified action for LLM input.
type ActionSummary struct {
Name string
Category string
Impact string
Hour int
}
// HeatPlanInput holds full day data for the 1-page plan.
type HeatPlanInput struct {
Summary SummaryInput
Timeline []TimelineSlotSummary
Actions []ActionSummary
CareChecklist []string
}

73
internal/report/data.go Normal file
View File

@@ -0,0 +1,73 @@
package report
import "time"
// DashboardData holds all data needed to render the HTML report.
type DashboardData struct {
GeneratedAt time.Time
ProfileName string
Date string
ShowNav bool
Warnings []WarningData
RiskLevel string
PeakTempC float64
MinNightTempC float64
PoorNightCool bool
RiskWindows []RiskWindowData
Timeline []TimelineSlotData
RoomBudgets []RoomBudgetData
CareChecklist []string
LLMSummary string
LLMDisclaimer string
}
// WarningData holds a weather warning for display.
type WarningData struct {
Headline string
Severity string
Description string
Instruction string
Onset string
Expires string
}
// RiskWindowData holds a risk window for display.
type RiskWindowData struct {
StartHour int
EndHour int
PeakTempC float64
Level string
Reason string
}
// TimelineSlotData holds one hour's data for the timeline.
type TimelineSlotData struct {
Hour int
HourStr string
TempC float64
RiskLevel string
BudgetStatus string
Actions []ActionData
}
// ActionData holds a single action for display.
type ActionData struct {
Name string
Category string
Effort string
Impact string
Description string
}
// RoomBudgetData holds a room's heat budget for display.
type RoomBudgetData struct {
RoomName string
InternalGainsW float64
SolarGainW float64
VentGainW float64
TotalGainW float64
TotalGainBTUH float64
ACCapacityBTUH float64
HeadroomBTUH float64
Status string
}

View File

@@ -0,0 +1,155 @@
package report
import (
"bytes"
_ "embed"
"fmt"
"html/template"
"io"
"github.com/cnachtigall/heatwave-autopilot/internal/static"
)
//go:embed templates/dashboard.html.tmpl
var dashboardTmpl string
// templateData extends DashboardData with CSS for the template.
type templateData struct {
DashboardData
CSS template.CSS
}
var funcMap = template.FuncMap{
"formatTemp": formatTemp,
"formatWatts": formatWatts,
"formatBTU": formatBTU,
"tempColor": tempColor,
"riskBadge": riskBadge,
"riskBg": riskBg,
"riskBadgeBg": riskBadgeBg,
"statusBadge": statusBadge,
"statusColor": statusColor,
"statusBadgeBg": statusBadgeBg,
}
// Generate renders the dashboard HTML to the given writer.
func Generate(w io.Writer, data DashboardData) error {
tmpl, err := template.New("dashboard").Funcs(funcMap).Parse(dashboardTmpl)
if err != nil {
return fmt.Errorf("parse template: %w", err)
}
td := templateData{
DashboardData: data,
CSS: template.CSS(static.TailwindCSS),
}
return tmpl.Execute(w, td)
}
// GenerateString renders the dashboard HTML to a string.
func GenerateString(data DashboardData) (string, error) {
var buf bytes.Buffer
if err := Generate(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
func formatTemp(c float64) string {
return fmt.Sprintf("%.1f°C", c)
}
func formatWatts(w float64) string {
return fmt.Sprintf("%.0f W", w)
}
func formatBTU(b float64) string {
return fmt.Sprintf("%.0f BTU/h", b)
}
func tempColor(c float64) string {
switch {
case c >= 40:
return "text-red-700 dark:text-red-400"
case c >= 35:
return "text-red-600 dark:text-red-400"
case c >= 30:
return "text-orange-600 dark:text-orange-400"
case c >= 25:
return "text-yellow-600 dark:text-yellow-400"
default:
return "text-green-600 dark:text-green-400"
}
}
func riskBadge(level string) string {
switch level {
case "extreme":
return "EXTREME"
case "high":
return "HIGH"
case "moderate":
return "MODERATE"
default:
return "LOW"
}
}
func riskBg(level string) string {
switch level {
case "extreme":
return "bg-red-50 dark:bg-red-950"
case "high":
return "bg-orange-50 dark:bg-orange-950"
case "moderate":
return "bg-yellow-50 dark:bg-yellow-950"
default:
return "bg-white dark:bg-gray-800"
}
}
func riskBadgeBg(level string) string {
switch level {
case "extreme":
return "bg-red-600"
case "high":
return "bg-orange-600"
case "moderate":
return "bg-yellow-600"
default:
return "bg-green-600"
}
}
func statusBadge(status string) string {
switch status {
case "overloaded":
return "OVERLOADED"
case "marginal":
return "MARGINAL"
default:
return "OK"
}
}
func statusColor(status string) string {
switch status {
case "overloaded":
return "text-red-600 dark:text-red-400"
case "marginal":
return "text-orange-600 dark:text-orange-400"
default:
return "text-green-600 dark:text-green-400"
}
}
func statusBadgeBg(status string) string {
switch status {
case "overloaded":
return "bg-red-600"
case "marginal":
return "bg-orange-600"
default:
return "bg-green-600"
}
}

View File

@@ -0,0 +1,176 @@
package report
import (
"os"
"strings"
"testing"
"time"
)
func testDashboardData() DashboardData {
return DashboardData{
GeneratedAt: time.Date(2025, 7, 15, 10, 0, 0, 0, time.UTC),
ProfileName: "home",
Date: "2025-07-15",
RiskLevel: "high",
PeakTempC: 37.2,
MinNightTempC: 22.5,
PoorNightCool: true,
Warnings: []WarningData{
{
Headline: "Heat warning Berlin",
Severity: "Severe",
Description: "Temperatures up to 37C",
Instruction: "Stay hydrated",
Onset: "2025-07-15 11:00",
Expires: "2025-07-16 19:00",
},
},
RiskWindows: []RiskWindowData{
{StartHour: 11, EndHour: 18, PeakTempC: 37.2, Level: "high", Reason: "very hot"},
},
Timeline: []TimelineSlotData{
{
Hour: 12, HourStr: "12:00", TempC: 35.5, RiskLevel: "high", BudgetStatus: "marginal",
Actions: []ActionData{
{Name: "Hydration reminder", Category: "hydration", Impact: "medium"},
},
},
{
Hour: 0, HourStr: "00:00", TempC: 22, RiskLevel: "low", BudgetStatus: "comfortable",
},
},
RoomBudgets: []RoomBudgetData{
{
RoomName: "Office",
InternalGainsW: 300,
SolarGainW: 600,
VentGainW: 150,
TotalGainW: 1050,
TotalGainBTUH: 3583,
ACCapacityBTUH: 8000,
HeadroomBTUH: 4417,
Status: "comfortable",
},
},
CareChecklist: []string{"Check elderly at 14:00"},
}
}
func TestGenerate_ProducesValidHTML(t *testing.T) {
html, err := GenerateString(testDashboardData())
if err != nil {
t.Fatalf("GenerateString: %v", err)
}
if !strings.Contains(html, "<!DOCTYPE html>") {
t.Error("missing DOCTYPE")
}
if !strings.Contains(html, "</html>") {
t.Error("missing closing html tag")
}
}
func TestGenerate_ContainsProfileAndDate(t *testing.T) {
html, err := GenerateString(testDashboardData())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(html, "home") {
t.Error("missing profile name")
}
if !strings.Contains(html, "2025-07-15") {
t.Error("missing date")
}
}
func TestGenerate_ContainsWarning(t *testing.T) {
html, err := GenerateString(testDashboardData())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(html, "Heat warning Berlin") {
t.Error("missing warning headline")
}
if !strings.Contains(html, "Stay hydrated") {
t.Error("missing warning instruction")
}
}
func TestGenerate_ContainsTimeline(t *testing.T) {
html, err := GenerateString(testDashboardData())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(html, "12:00") {
t.Error("missing timeline hour")
}
if !strings.Contains(html, "Hydration reminder") {
t.Error("missing timeline action")
}
}
func TestGenerate_ContainsRoomBudget(t *testing.T) {
html, err := GenerateString(testDashboardData())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(html, "Office") {
t.Error("missing room name")
}
if !strings.Contains(html, "8000 BTU/h") {
t.Error("missing AC capacity")
}
}
func TestGenerate_ContainsCareChecklist(t *testing.T) {
html, err := GenerateString(testDashboardData())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(html, "Check elderly at 14:00") {
t.Error("missing care checklist item")
}
}
func TestGenerate_ContainsCSS(t *testing.T) {
html, err := GenerateString(testDashboardData())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(html, "box-sizing") {
t.Error("missing inlined CSS")
}
}
func TestGenerate_EmptyData(t *testing.T) {
_, err := GenerateString(DashboardData{
GeneratedAt: time.Now(),
ProfileName: "test",
Date: "2025-07-15",
})
if err != nil {
t.Fatalf("should handle empty data: %v", err)
}
}
func TestGenerate_WriteFile(t *testing.T) {
path := t.TempDir() + "/report.html"
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := Generate(f, testDashboardData()); err != nil {
t.Fatalf("Generate to file: %v", err)
}
f.Close()
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Size() < 1000 {
t.Errorf("report file too small: %d bytes", info.Size())
}
}

View File

@@ -0,0 +1,102 @@
package report
import (
"fmt"
"os"
"testing"
"time"
)
func TestGenerateSampleReport(t *testing.T) {
if os.Getenv("WRITE_SAMPLE") == "" {
t.Skip("set WRITE_SAMPLE=1 to generate sample report")
}
temps := []float64{22, 21, 20.5, 20, 19.5, 19.5, 20, 22, 24, 27, 30, 32, 34, 35.5, 37.2, 36.8, 35, 33, 31, 28, 26, 24.5, 23.5, 22.5}
data := DashboardData{
GeneratedAt: time.Now(),
ProfileName: "home — Berlin",
Date: "2025-07-15",
RiskLevel: "high",
PeakTempC: 37.2,
MinNightTempC: 19.5,
PoorNightCool: true,
Warnings: []WarningData{
{
Headline: "Amtliche WARNUNG vor HITZE",
Severity: "Severe",
Description: "Es tritt eine starke Wärmebelastung auf. Temperaturen bis 37°C erwartet.",
Instruction: "Trinken Sie ausreichend. Vermeiden Sie direkte Sonneneinstrahlung.",
Onset: "2025-07-15 11:00",
Expires: "2025-07-16 19:00",
},
},
RiskWindows: []RiskWindowData{
{StartHour: 10, EndHour: 18, PeakTempC: 37.2, Level: "high", Reason: "very hot daytime temperatures"},
},
RoomBudgets: []RoomBudgetData{
{
RoomName: "Office", InternalGainsW: 300, SolarGainW: 622, VentGainW: 150,
TotalGainW: 1072, TotalGainBTUH: 3658, ACCapacityBTUH: 8000, HeadroomBTUH: 4342, Status: "comfortable",
},
{
RoomName: "Bedroom", InternalGainsW: 100, SolarGainW: 200, VentGainW: 80,
TotalGainW: 380, TotalGainBTUH: 1297, ACCapacityBTUH: 0, HeadroomBTUH: -1297, Status: "overloaded",
},
},
CareChecklist: []string{
"Check elderly occupant (bedroom) at 10:00, 14:00, 18:00",
"Ensure water bottles are filled and accessible",
"Verify medication storage temperature",
},
}
for i, temp := range temps {
slot := TimelineSlotData{
Hour: i,
HourStr: fmt.Sprintf("%02d:00", i),
TempC: temp,
}
switch {
case temp >= 35:
slot.RiskLevel = "high"
slot.BudgetStatus = "marginal"
case temp >= 30:
slot.RiskLevel = "moderate"
slot.BudgetStatus = "comfortable"
default:
slot.RiskLevel = "low"
slot.BudgetStatus = "comfortable"
}
isDay := i >= 6 && i < 21
if i >= 11 && i <= 18 && temp >= 30 {
slot.Actions = append(slot.Actions, ActionData{Name: "Hydration reminder", Category: "hydration", Impact: "medium"})
}
if i >= 10 && i <= 18 && temp >= 30 {
slot.Actions = append(slot.Actions, ActionData{Name: "Check vulnerable occupants", Category: "care", Impact: "high"})
}
if i <= 10 && temp >= 25 {
slot.Actions = append(slot.Actions, ActionData{Name: "Close south-facing shutters", Category: "shading", Impact: "high"})
}
if i >= 6 && i <= 9 {
slot.Actions = append(slot.Actions, ActionData{Name: "Pre-cool rooms with AC", Category: "ac_strategy", Impact: "high"})
}
if !isDay && temp < 25 {
slot.Actions = append(slot.Actions, ActionData{Name: "Night ventilation", Category: "ventilation", Impact: "high"})
}
data.Timeline = append(data.Timeline, slot)
}
f, err := os.Create("/tmp/heatwave-sample-report.html")
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := Generate(f, data); err != nil {
t.Fatalf("Generate: %v", err)
}
t.Log("Sample report written to /tmp/heatwave-sample-report.html")
}

View File

@@ -0,0 +1,166 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Heatwave Report — {{.ProfileName}} — {{.Date}}</title>
<style>{{.CSS}}</style>
</head>
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
{{if .ShowNav}}
<nav class="bg-white dark:bg-gray-800 shadow mb-4">
<div class="container mx-auto flex items-center gap-6 px-4 py-3">
<span class="font-bold text-lg">Heatwave</span>
<a href="/" class="font-medium text-blue-600 dark:text-blue-400 underline">Dashboard</a>
<a href="/setup" class="text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Setup</a>
</div>
</nav>
{{end}}
<div class="container mx-auto py-4">
<header class="mb-6">
<h1 class="text-3xl font-bold">Heatwave Report</h1>
<p class="text-gray-600 dark:text-gray-400">{{.ProfileName}} — {{.Date}}</p>
<p class="text-xs text-gray-500 dark:text-gray-500">Generated {{.GeneratedAt.Format "2006-01-02 15:04"}}</p>
</header>
{{template "warnings" .}}
{{template "risk_summary" .}}
{{template "timeline" .}}
{{template "heatbudget" .}}
{{template "checklist" .}}
{{if .LLMSummary}}
<section class="mt-8 p-4 bg-blue-50 dark:bg-blue-950 rounded-lg">
<h2 class="text-xl font-semibold mb-2">AI Summary</h2>
{{if .LLMDisclaimer}}<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">{{.LLMDisclaimer}}</p>{{end}}
<div>{{.LLMSummary}}</div>
</section>
{{end}}
<footer class="mt-8 text-center text-xs text-gray-500 dark:text-gray-500 py-4">
<p>Heatwave Autopilot — This report is for planning purposes only. It does not constitute medical advice.</p>
</footer>
</div>
</body>
</html>
{{define "warnings"}}
{{if .Warnings}}
<section class="mb-6">
{{range .Warnings}}
<div class="p-4 mb-2 border-l-4 {{if eq .Severity "Extreme"}}border-red-400 bg-red-50 dark:bg-red-950{{else}}border-orange-400 bg-orange-50 dark:bg-orange-950{{end}} rounded">
<p class="font-bold {{if eq .Severity "Extreme"}}text-red-700 dark:text-red-400{{else}}text-orange-600 dark:text-orange-400{{end}}">{{.Headline}}</p>
<p class="text-sm text-gray-700 dark:text-gray-300">{{.Description}}</p>
{{if .Instruction}}<p class="text-sm font-medium mt-1">{{.Instruction}}</p>{{end}}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{.Onset}} — {{.Expires}}</p>
</div>
{{end}}
</section>
{{end}}
{{end}}
{{define "risk_summary"}}
<section class="mb-6 grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="p-4 rounded-lg shadow dark:shadow-gray-700 {{riskBg .RiskLevel}}">
<p class="text-sm text-gray-600 dark:text-gray-400">Risk Level</p>
<p class="text-2xl font-bold">{{riskBadge .RiskLevel}}</p>
</div>
<div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400">Peak Temperature</p>
<p class="text-2xl font-bold {{tempColor .PeakTempC}}">{{formatTemp .PeakTempC}}</p>
</div>
<div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400">Min Night Temp</p>
<p class="text-2xl font-bold">{{formatTemp .MinNightTempC}}</p>
{{if .PoorNightCool}}<p class="text-xs text-red-600 dark:text-red-400 font-medium">Poor night cooling</p>{{end}}
</div>
</section>
{{if .RiskWindows}}
<section class="mb-6">
<h2 class="text-xl font-semibold mb-2">Risk Windows</h2>
<div class="space-y-2">
{{range .RiskWindows}}
<div class="p-3 rounded {{riskBg .Level}} flex justify-between items-center">
<span class="font-medium">{{printf "%02d:00" .StartHour}} — {{printf "%02d:00" .EndHour}}</span>
<span>Peak {{formatTemp .PeakTempC}}</span>
<span class="px-2 py-1 rounded-full text-xs font-semibold text-white {{riskBadgeBg .Level}}">{{.Level}}</span>
</div>
{{end}}
</div>
</section>
{{end}}
{{end}}
{{define "timeline"}}
<section class="mb-6">
<h2 class="text-xl font-semibold mb-2">Hour-by-Hour Timeline</h2>
<div class="overflow-hidden rounded-lg shadow dark:shadow-gray-700">
<table>
<thead>
<tr class="dark:bg-gray-800">
<th>Hour</th>
<th>Temp</th>
<th>Risk</th>
<th>Budget</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{range .Timeline}}
<tr class="{{riskBg .RiskLevel}}">
<td class="font-medium">{{.HourStr}}</td>
<td class="{{tempColor .TempC}}">{{formatTemp .TempC}}</td>
<td>{{riskBadge .RiskLevel}}</td>
<td>{{statusBadge .BudgetStatus}}</td>
<td>
{{range .Actions}}
<span class="inline-block px-2 py-1 text-xs rounded bg-blue-100 dark:bg-blue-900 dark:text-blue-200 mb-1">{{.Name}}</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</section>
{{end}}
{{define "heatbudget"}}
{{if .RoomBudgets}}
<section class="mb-6">
<h2 class="text-xl font-semibold mb-2">Room Heat Budgets</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{{range .RoomBudgets}}
<div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
<h3 class="font-semibold text-lg mb-2">{{.RoomName}}</h3>
<table class="text-sm">
<tr><td class="text-gray-600 dark:text-gray-400">Internal gains</td><td class="font-medium">{{formatWatts .InternalGainsW}}</td></tr>
<tr><td class="text-gray-600 dark:text-gray-400">Solar gain</td><td class="font-medium">{{formatWatts .SolarGainW}}</td></tr>
<tr><td class="text-gray-600 dark:text-gray-400">Ventilation</td><td class="font-medium">{{formatWatts .VentGainW}}</td></tr>
<tr class="font-bold"><td>Total heat load</td><td>{{formatWatts .TotalGainW}} ({{formatBTU .TotalGainBTUH}})</td></tr>
<tr><td class="text-gray-600 dark:text-gray-400">AC capacity</td><td>{{formatBTU .ACCapacityBTUH}}</td></tr>
<tr class="font-bold"><td>Headroom</td><td class="{{statusColor .Status}}">{{formatBTU .HeadroomBTUH}}</td></tr>
</table>
<p class="mt-2 px-3 py-1 rounded-full inline-block text-xs font-semibold text-white {{statusBadgeBg .Status}}">{{.Status}}</p>
</div>
{{end}}
</div>
</section>
{{end}}
{{end}}
{{define "checklist"}}
{{if .CareChecklist}}
<section class="mb-6">
<h2 class="text-xl font-semibold mb-2">Care Checklist</h2>
<ul class="list-disc list-inside space-y-2 p-4 bg-white dark:bg-gray-800 rounded-lg shadow dark:shadow-gray-700">
{{range .CareChecklist}}
<li>{{.}}</li>
{{end}}
</ul>
</section>
{{end}}
{{end}}

167
internal/risk/analyzer.go Normal file
View File

@@ -0,0 +1,167 @@
package risk
import "math"
// RiskLevel represents the severity of heat risk.
type RiskLevel int
const (
Low RiskLevel = iota
Moderate
High
Extreme
)
func (r RiskLevel) String() string {
switch r {
case Low:
return "low"
case Moderate:
return "moderate"
case High:
return "high"
case Extreme:
return "extreme"
default:
return "unknown"
}
}
// HourlyData holds weather data for a single hour.
type HourlyData struct {
Hour int
TempC float64
ApparentC float64
HumidityPct float64
IsDay bool
}
// RiskWindow represents a contiguous block of hours with elevated heat risk.
type RiskWindow struct {
StartHour int
EndHour int
PeakTempC float64
Level RiskLevel
Reason string
}
// DayRisk holds the overall risk assessment for a day.
type DayRisk struct {
Level RiskLevel
PeakTempC float64
MinNightTempC float64
PoorNightCool bool
Windows []RiskWindow
}
// isNightHour returns true for hours 21-23 and 0-6.
func isNightHour(hour int) bool {
return hour >= 21 || hour <= 6
}
// riskLevelForTemp returns the risk level based on temperature and thresholds.
func riskLevelForTemp(tempC float64, th Thresholds) RiskLevel {
switch {
case tempC >= th.ExtremeDayC:
return Extreme
case tempC >= th.VeryHotDayC:
return High
case tempC >= th.HotDayC:
return Moderate
default:
return Low
}
}
// AnalyzeDay analyzes 24 hourly data points and returns the overall day risk.
func AnalyzeDay(hours []HourlyData, th Thresholds) DayRisk {
if len(hours) == 0 {
return DayRisk{Level: Low, MinNightTempC: math.Inf(1)}
}
result := DayRisk{
Level: Low,
MinNightTempC: math.Inf(1),
}
// Find peak temp and min night temp
for _, h := range hours {
if h.TempC > result.PeakTempC {
result.PeakTempC = h.TempC
}
if isNightHour(h.Hour) {
if h.TempC < result.MinNightTempC {
result.MinNightTempC = h.TempC
}
if h.TempC >= th.PoorNightCoolingC {
result.PoorNightCool = true
}
}
}
// If no night hours were seen, set MinNightTempC to 0
if math.IsInf(result.MinNightTempC, 1) {
result.MinNightTempC = 0
}
// Find contiguous risk windows (hours where temp >= HotDayC)
var currentWindow *RiskWindow
for _, h := range hours {
level := riskLevelForTemp(h.TempC, th)
if level >= Moderate {
if currentWindow == nil {
currentWindow = &RiskWindow{
StartHour: h.Hour,
EndHour: h.Hour,
PeakTempC: h.TempC,
Level: level,
}
} else {
currentWindow.EndHour = h.Hour
if h.TempC > currentWindow.PeakTempC {
currentWindow.PeakTempC = h.TempC
}
if level > currentWindow.Level {
currentWindow.Level = level
}
}
} else {
if currentWindow != nil {
currentWindow.Reason = reasonForLevel(currentWindow.Level)
result.Windows = append(result.Windows, *currentWindow)
currentWindow = nil
}
}
}
if currentWindow != nil {
currentWindow.Reason = reasonForLevel(currentWindow.Level)
result.Windows = append(result.Windows, *currentWindow)
}
// Overall level = max of all windows
for _, w := range result.Windows {
if w.Level > result.Level {
result.Level = w.Level
}
}
// Poor night cooling elevates by one level (capped at Extreme)
if result.PoorNightCool && result.Level > Low && result.Level < Extreme {
result.Level++
}
return result
}
func reasonForLevel(level RiskLevel) string {
switch level {
case Moderate:
return "hot daytime temperatures"
case High:
return "very hot daytime temperatures"
case Extreme:
return "extreme heat"
default:
return ""
}
}

View File

@@ -0,0 +1,166 @@
package risk
import (
"testing"
)
func makeHours(temps []float64) []HourlyData {
hours := make([]HourlyData, len(temps))
for i, t := range temps {
hours[i] = HourlyData{
Hour: i,
TempC: t,
ApparentC: t,
HumidityPct: 50,
IsDay: i >= 6 && i < 21,
}
}
return hours
}
func TestAnalyzeDay_CoolDay(t *testing.T) {
temps := make([]float64, 24)
for i := range temps {
temps[i] = 20 + float64(i%5) // 20-24C
}
result := AnalyzeDay(makeHours(temps), DefaultThresholds())
if result.Level != Low {
t.Errorf("Level = %v, want Low", result.Level)
}
if len(result.Windows) != 0 {
t.Errorf("Windows = %d, want 0", len(result.Windows))
}
}
func TestAnalyzeDay_ModeratelyHot(t *testing.T) {
temps := make([]float64, 24)
for i := range temps {
temps[i] = 18 // base below poor night cooling threshold
}
// Hot window 11-15
for i := 11; i <= 15; i++ {
temps[i] = 32
}
result := AnalyzeDay(makeHours(temps), DefaultThresholds())
if result.Level != Moderate {
t.Errorf("Level = %v, want Moderate", result.Level)
}
if len(result.Windows) != 1 {
t.Fatalf("Windows = %d, want 1", len(result.Windows))
}
w := result.Windows[0]
if w.StartHour != 11 || w.EndHour != 15 {
t.Errorf("Window = %d-%d, want 11-15", w.StartHour, w.EndHour)
}
if w.PeakTempC != 32 {
t.Errorf("PeakTempC = %v, want 32", w.PeakTempC)
}
}
func TestAnalyzeDay_VeryHot(t *testing.T) {
temps := make([]float64, 24)
for i := range temps {
temps[i] = 18 // below poor night cooling threshold
}
for i := 10; i <= 17; i++ {
temps[i] = 37
}
result := AnalyzeDay(makeHours(temps), DefaultThresholds())
if result.Level != High {
t.Errorf("Level = %v, want High", result.Level)
}
}
func TestAnalyzeDay_Extreme(t *testing.T) {
temps := make([]float64, 24)
for i := range temps {
temps[i] = 25
}
for i := 12; i <= 16; i++ {
temps[i] = 41
}
result := AnalyzeDay(makeHours(temps), DefaultThresholds())
if result.Level != Extreme {
t.Errorf("Level = %v, want Extreme", result.Level)
}
}
func TestAnalyzeDay_PoorNightCoolingElevates(t *testing.T) {
temps := make([]float64, 24)
for i := range temps {
temps[i] = 22
}
// Hot day window
for i := 11; i <= 15; i++ {
temps[i] = 32
}
// Poor night cooling (hour 0-6 and 21-23 above 20C)
for i := 0; i <= 6; i++ {
temps[i] = 22
}
for i := 21; i < 24; i++ {
temps[i] = 22
}
result := AnalyzeDay(makeHours(temps), DefaultThresholds())
if !result.PoorNightCool {
t.Error("expected PoorNightCool = true")
}
// Base Moderate + poor night = High
if result.Level != High {
t.Errorf("Level = %v, want High (elevated from Moderate)", result.Level)
}
}
func TestAnalyzeDay_GoodNightCooling(t *testing.T) {
temps := make([]float64, 24)
for i := range temps {
temps[i] = 18 // cool nights
}
for i := 11; i <= 15; i++ {
temps[i] = 32
}
result := AnalyzeDay(makeHours(temps), DefaultThresholds())
if result.PoorNightCool {
t.Error("expected PoorNightCool = false")
}
if result.Level != Moderate {
t.Errorf("Level = %v, want Moderate (no elevation)", result.Level)
}
}
func TestAnalyzeDay_Empty(t *testing.T) {
result := AnalyzeDay(nil, DefaultThresholds())
if result.Level != Low {
t.Errorf("Level = %v, want Low", result.Level)
}
}
func TestAnalyzeDay_MinNightTemp(t *testing.T) {
temps := make([]float64, 24)
for i := range temps {
temps[i] = 25
}
temps[3] = 16.5 // coldest night hour
result := AnalyzeDay(makeHours(temps), DefaultThresholds())
if result.MinNightTempC != 16.5 {
t.Errorf("MinNightTempC = %v, want 16.5", result.MinNightTempC)
}
}
func TestRiskLevelString(t *testing.T) {
tests := []struct {
level RiskLevel
want string
}{
{Low, "low"},
{Moderate, "moderate"},
{High, "high"},
{Extreme, "extreme"},
{RiskLevel(99), "unknown"},
}
for _, tt := range tests {
if got := tt.level.String(); got != tt.want {
t.Errorf("RiskLevel(%d).String() = %s, want %s", tt.level, got, tt.want)
}
}
}

View File

@@ -0,0 +1,21 @@
package risk
// Thresholds holds configurable temperature thresholds for risk analysis.
type Thresholds struct {
HotDayC float64 // daytime temp considered "hot" (default 30)
VeryHotDayC float64 // daytime temp considered "very hot" (default 35)
ExtremeDayC float64 // extreme heat (default 40)
PoorNightCoolingC float64 // night temp above which cooling is poor (default 20)
ComfortMaxC float64 // max indoor comfort temp (default 26)
}
// DefaultThresholds returns the default temperature thresholds.
func DefaultThresholds() Thresholds {
return Thresholds{
HotDayC: 30,
VeryHotDayC: 35,
ExtremeDayC: 40,
PoorNightCoolingC: 20,
ComfortMaxC: 26,
}
}

6
internal/static/embed.go Normal file
View File

@@ -0,0 +1,6 @@
package static
import _ "embed"
//go:embed tailwind.css
var TailwindCSS string

File diff suppressed because one or more lines are too long

133
internal/store/ac.go Normal file
View File

@@ -0,0 +1,133 @@
package store
import (
"database/sql"
"fmt"
"time"
)
type ACUnit struct {
ID int64
ProfileID int64
Name string
ACType string
CapacityBTU float64
HasDehumidify bool
EfficiencyEER float64
CreatedAt time.Time
}
func (s *Store) CreateACUnit(profileID int64, name, acType string, capacityBTU float64, hasDehumidify bool, efficiencyEER float64) (*ACUnit, error) {
if acType == "" {
acType = "portable"
}
if efficiencyEER == 0 {
efficiencyEER = 10.0
}
dehumid := 0
if hasDehumidify {
dehumid = 1
}
res, err := s.db.Exec(
`INSERT INTO ac_units (profile_id, name, ac_type, capacity_btu, has_dehumidify, efficiency_eer) VALUES (?, ?, ?, ?, ?, ?)`,
profileID, name, acType, capacityBTU, dehumid, efficiencyEER,
)
if err != nil {
return nil, fmt.Errorf("create ac unit: %w", err)
}
id, _ := res.LastInsertId()
return s.GetACUnit(id)
}
func (s *Store) GetACUnit(id int64) (*ACUnit, error) {
a := &ACUnit{}
var created string
var dehumid int
err := s.db.QueryRow(
`SELECT id, profile_id, name, ac_type, capacity_btu, has_dehumidify, efficiency_eer, created_at FROM ac_units WHERE id = ?`, id,
).Scan(&a.ID, &a.ProfileID, &a.Name, &a.ACType, &a.CapacityBTU, &dehumid, &a.EfficiencyEER, &created)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("ac unit not found: %d", id)
}
if err != nil {
return nil, fmt.Errorf("get ac unit: %w", err)
}
a.HasDehumidify = dehumid != 0
a.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
return a, nil
}
func (s *Store) ListACUnits(profileID int64) ([]ACUnit, error) {
rows, err := s.db.Query(
`SELECT id, profile_id, name, ac_type, capacity_btu, has_dehumidify, efficiency_eer, created_at FROM ac_units WHERE profile_id = ? ORDER BY name`, profileID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var units []ACUnit
for rows.Next() {
var a ACUnit
var created string
var dehumid int
if err := rows.Scan(&a.ID, &a.ProfileID, &a.Name, &a.ACType, &a.CapacityBTU, &dehumid, &a.EfficiencyEER, &created); err != nil {
return nil, err
}
a.HasDehumidify = dehumid != 0
a.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
units = append(units, a)
}
return units, rows.Err()
}
func (s *Store) UpdateACUnit(id int64, field, value string) error {
allowed := map[string]bool{
"name": true, "ac_type": true, "capacity_btu": true,
"has_dehumidify": true, "efficiency_eer": true,
}
if !allowed[field] {
return fmt.Errorf("invalid field: %s", field)
}
_, err := s.db.Exec(
fmt.Sprintf(`UPDATE ac_units SET %s = ? WHERE id = ?`, field), value, id,
)
return err
}
func (s *Store) DeleteACUnit(id int64) error {
_, err := s.db.Exec(`DELETE FROM ac_units WHERE id = ?`, id)
return err
}
func (s *Store) AssignACToRoom(acID, roomID int64) error {
_, err := s.db.Exec(
`INSERT OR IGNORE INTO ac_room_assignments (ac_id, room_id) VALUES (?, ?)`,
acID, roomID,
)
return err
}
func (s *Store) UnassignACFromRoom(acID, roomID int64) error {
_, err := s.db.Exec(
`DELETE FROM ac_room_assignments WHERE ac_id = ? AND room_id = ?`,
acID, roomID,
)
return err
}
func (s *Store) GetACRoomAssignments(acID int64) ([]int64, error) {
rows, err := s.db.Query(`SELECT room_id FROM ac_room_assignments WHERE ac_id = ?`, acID)
if err != nil {
return nil, err
}
defer rows.Close()
var roomIDs []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
roomIDs = append(roomIDs, id)
}
return roomIDs, rows.Err()
}

113
internal/store/device.go Normal file
View File

@@ -0,0 +1,113 @@
package store
import (
"database/sql"
"fmt"
"time"
)
type Device struct {
ID int64
RoomID int64
Name string
DeviceType string
WattsIdle float64
WattsTypical float64
WattsPeak float64
DutyCycle float64
Schedule string
CreatedAt time.Time
}
func (s *Store) CreateDevice(roomID int64, name, deviceType string, wattsIdle, wattsTypical, wattsPeak, dutyCycle float64) (*Device, error) {
if dutyCycle == 0 {
dutyCycle = 1.0
}
res, err := s.db.Exec(
`INSERT INTO devices (room_id, name, device_type, watts_idle, watts_typical, watts_peak, duty_cycle) VALUES (?, ?, ?, ?, ?, ?, ?)`,
roomID, name, deviceType, wattsIdle, wattsTypical, wattsPeak, dutyCycle,
)
if err != nil {
return nil, fmt.Errorf("create device: %w", err)
}
id, _ := res.LastInsertId()
return s.GetDevice(id)
}
func (s *Store) GetDevice(id int64) (*Device, error) {
d := &Device{}
var created string
err := s.db.QueryRow(
`SELECT id, room_id, name, device_type, watts_idle, watts_typical, watts_peak, duty_cycle, schedule, created_at FROM devices WHERE id = ?`, id,
).Scan(&d.ID, &d.RoomID, &d.Name, &d.DeviceType, &d.WattsIdle, &d.WattsTypical, &d.WattsPeak, &d.DutyCycle, &d.Schedule, &created)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("device not found: %d", id)
}
if err != nil {
return nil, fmt.Errorf("get device: %w", err)
}
d.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
return d, nil
}
func (s *Store) ListDevices(roomID int64) ([]Device, error) {
rows, err := s.db.Query(
`SELECT id, room_id, name, device_type, watts_idle, watts_typical, watts_peak, duty_cycle, schedule, created_at FROM devices WHERE room_id = ? ORDER BY name`, roomID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var devices []Device
for rows.Next() {
var d Device
var created string
if err := rows.Scan(&d.ID, &d.RoomID, &d.Name, &d.DeviceType, &d.WattsIdle, &d.WattsTypical, &d.WattsPeak, &d.DutyCycle, &d.Schedule, &created); err != nil {
return nil, err
}
d.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
devices = append(devices, d)
}
return devices, rows.Err()
}
func (s *Store) ListAllDevices(profileID int64) ([]Device, error) {
rows, err := s.db.Query(
`SELECT d.id, d.room_id, d.name, d.device_type, d.watts_idle, d.watts_typical, d.watts_peak, d.duty_cycle, d.schedule, d.created_at
FROM devices d JOIN rooms r ON d.room_id = r.id WHERE r.profile_id = ? ORDER BY d.name`, profileID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var devices []Device
for rows.Next() {
var d Device
var created string
if err := rows.Scan(&d.ID, &d.RoomID, &d.Name, &d.DeviceType, &d.WattsIdle, &d.WattsTypical, &d.WattsPeak, &d.DutyCycle, &d.Schedule, &created); err != nil {
return nil, err
}
d.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
devices = append(devices, d)
}
return devices, rows.Err()
}
func (s *Store) UpdateDevice(id int64, field, value string) error {
allowed := map[string]bool{
"name": true, "device_type": true, "watts_idle": true, "watts_typical": true,
"watts_peak": true, "duty_cycle": true, "schedule": true,
}
if !allowed[field] {
return fmt.Errorf("invalid field: %s", field)
}
_, err := s.db.Exec(
fmt.Sprintf(`UPDATE devices SET %s = ? WHERE id = ?`, field), value, id,
)
return err
}
func (s *Store) DeleteDevice(id int64) error {
_, err := s.db.Exec(`DELETE FROM devices WHERE id = ?`, id)
return err
}

View File

@@ -0,0 +1,96 @@
package store
import (
"fmt"
"time"
)
type Forecast struct {
ID int64
ProfileID int64
Timestamp time.Time
TemperatureC *float64
HumidityPct *float64
WindSpeedMs *float64
CloudCoverPct *float64
PrecipitationMm *float64
SunshineMin *float64
PressureHpa *float64
DewPointC *float64
ApparentTempC *float64
Condition string
Source string
FetchedAt time.Time
}
func (s *Store) UpsertForecast(f *Forecast) error {
_, err := s.db.Exec(
`INSERT INTO forecasts (profile_id, timestamp, temperature_c, humidity_pct, wind_speed_ms, cloud_cover_pct, precipitation_mm, sunshine_min, pressure_hpa, dew_point_c, apparent_temp_c, condition, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(profile_id, timestamp, source) DO UPDATE SET
temperature_c=excluded.temperature_c, humidity_pct=excluded.humidity_pct,
wind_speed_ms=excluded.wind_speed_ms, cloud_cover_pct=excluded.cloud_cover_pct,
precipitation_mm=excluded.precipitation_mm, sunshine_min=excluded.sunshine_min,
pressure_hpa=excluded.pressure_hpa, dew_point_c=excluded.dew_point_c,
apparent_temp_c=excluded.apparent_temp_c, condition=excluded.condition,
fetched_at=datetime('now')`,
f.ProfileID, f.Timestamp.Format(time.RFC3339),
f.TemperatureC, f.HumidityPct, f.WindSpeedMs, f.CloudCoverPct,
f.PrecipitationMm, f.SunshineMin, f.PressureHpa, f.DewPointC,
f.ApparentTempC, f.Condition, f.Source,
)
return err
}
func (s *Store) GetForecasts(profileID int64, from, to time.Time, source string) ([]Forecast, error) {
query := `SELECT id, profile_id, timestamp, temperature_c, humidity_pct, wind_speed_ms, cloud_cover_pct, precipitation_mm, sunshine_min, pressure_hpa, dew_point_c, apparent_temp_c, condition, source, fetched_at
FROM forecasts WHERE profile_id = ? AND timestamp >= ? AND timestamp <= ?`
args := []any{profileID, from.Format(time.RFC3339), to.Format(time.RFC3339)}
if source != "" {
query += ` AND source = ?`
args = append(args, source)
}
query += ` ORDER BY timestamp`
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var forecasts []Forecast
for rows.Next() {
var f Forecast
var ts, fetched string
if err := rows.Scan(&f.ID, &f.ProfileID, &ts, &f.TemperatureC, &f.HumidityPct, &f.WindSpeedMs, &f.CloudCoverPct, &f.PrecipitationMm, &f.SunshineMin, &f.PressureHpa, &f.DewPointC, &f.ApparentTempC, &f.Condition, &f.Source, &fetched); err != nil {
return nil, err
}
f.Timestamp, _ = time.Parse(time.RFC3339, ts)
f.FetchedAt, _ = time.Parse("2006-01-02 15:04:05", fetched)
forecasts = append(forecasts, f)
}
return forecasts, rows.Err()
}
func (s *Store) GetLastFetchTime(profileID int64, source string) (time.Time, error) {
var fetched string
err := s.db.QueryRow(
`SELECT MAX(fetched_at) FROM forecasts WHERE profile_id = ? AND source = ?`,
profileID, source,
).Scan(&fetched)
if err != nil || fetched == "" {
return time.Time{}, fmt.Errorf("no forecasts found")
}
t, _ := time.Parse("2006-01-02 15:04:05", fetched)
return t, nil
}
func (s *Store) CleanupOldForecasts(olderThan time.Time) (int64, error) {
res, err := s.db.Exec(
`DELETE FROM forecasts WHERE timestamp < ?`,
olderThan.Format(time.RFC3339),
)
if err != nil {
return 0, err
}
return res.RowsAffected()
}

View File

@@ -0,0 +1,6 @@
package store
import _ "embed"
//go:embed schema.sql
var schemaSQL string

108
internal/store/occupant.go Normal file
View File

@@ -0,0 +1,108 @@
package store
import (
"database/sql"
"fmt"
)
type Occupant struct {
ID int64
RoomID int64
Count int
ActivityLevel string
Vulnerable bool
}
func (s *Store) CreateOccupant(roomID int64, count int, activityLevel string, vulnerable bool) (*Occupant, error) {
if activityLevel == "" {
activityLevel = "sedentary"
}
vuln := 0
if vulnerable {
vuln = 1
}
res, err := s.db.Exec(
`INSERT INTO occupants (room_id, count, activity_level, vulnerable) VALUES (?, ?, ?, ?)`,
roomID, count, activityLevel, vuln,
)
if err != nil {
return nil, fmt.Errorf("create occupant: %w", err)
}
id, _ := res.LastInsertId()
return s.GetOccupant(id)
}
func (s *Store) GetOccupant(id int64) (*Occupant, error) {
o := &Occupant{}
var vuln int
err := s.db.QueryRow(
`SELECT id, room_id, count, activity_level, vulnerable FROM occupants WHERE id = ?`, id,
).Scan(&o.ID, &o.RoomID, &o.Count, &o.ActivityLevel, &vuln)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("occupant not found: %d", id)
}
if err != nil {
return nil, fmt.Errorf("get occupant: %w", err)
}
o.Vulnerable = vuln != 0
return o, nil
}
func (s *Store) ListOccupants(roomID int64) ([]Occupant, error) {
rows, err := s.db.Query(
`SELECT id, room_id, count, activity_level, vulnerable FROM occupants WHERE room_id = ?`, roomID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var occupants []Occupant
for rows.Next() {
var o Occupant
var vuln int
if err := rows.Scan(&o.ID, &o.RoomID, &o.Count, &o.ActivityLevel, &vuln); err != nil {
return nil, err
}
o.Vulnerable = vuln != 0
occupants = append(occupants, o)
}
return occupants, rows.Err()
}
func (s *Store) ListAllOccupants(profileID int64) ([]Occupant, error) {
rows, err := s.db.Query(
`SELECT o.id, o.room_id, o.count, o.activity_level, o.vulnerable
FROM occupants o JOIN rooms r ON o.room_id = r.id WHERE r.profile_id = ?`, profileID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var occupants []Occupant
for rows.Next() {
var o Occupant
var vuln int
if err := rows.Scan(&o.ID, &o.RoomID, &o.Count, &o.ActivityLevel, &vuln); err != nil {
return nil, err
}
o.Vulnerable = vuln != 0
occupants = append(occupants, o)
}
return occupants, rows.Err()
}
func (s *Store) UpdateOccupant(id int64, field, value string) error {
allowed := map[string]bool{"count": true, "activity_level": true, "vulnerable": true}
if !allowed[field] {
return fmt.Errorf("invalid field: %s", field)
}
_, err := s.db.Exec(
fmt.Sprintf(`UPDATE occupants SET %s = ? WHERE id = ?`, field), value, id,
)
return err
}
func (s *Store) DeleteOccupant(id int64) error {
_, err := s.db.Exec(`DELETE FROM occupants WHERE id = ?`, id)
return err
}

103
internal/store/profile.go Normal file
View File

@@ -0,0 +1,103 @@
package store
import (
"database/sql"
"fmt"
"time"
)
type Profile struct {
ID int64
Name string
Latitude float64
Longitude float64
Timezone string
CreatedAt time.Time
UpdatedAt time.Time
}
func (s *Store) CreateProfile(name string, lat, lon float64, tz string) (*Profile, error) {
if tz == "" {
tz = "Europe/Berlin"
}
res, err := s.db.Exec(
`INSERT INTO profiles (name, latitude, longitude, timezone) VALUES (?, ?, ?, ?)`,
name, lat, lon, tz,
)
if err != nil {
return nil, fmt.Errorf("create profile: %w", err)
}
id, _ := res.LastInsertId()
return s.GetProfile(id)
}
func (s *Store) GetProfile(id int64) (*Profile, error) {
p := &Profile{}
var created, updated string
err := s.db.QueryRow(
`SELECT id, name, latitude, longitude, timezone, created_at, updated_at FROM profiles WHERE id = ?`, id,
).Scan(&p.ID, &p.Name, &p.Latitude, &p.Longitude, &p.Timezone, &created, &updated)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("profile not found: %d", id)
}
if err != nil {
return nil, fmt.Errorf("get profile: %w", err)
}
p.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
p.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updated)
return p, nil
}
func (s *Store) GetProfileByName(name string) (*Profile, error) {
p := &Profile{}
var created, updated string
err := s.db.QueryRow(
`SELECT id, name, latitude, longitude, timezone, created_at, updated_at FROM profiles WHERE name = ?`, name,
).Scan(&p.ID, &p.Name, &p.Latitude, &p.Longitude, &p.Timezone, &created, &updated)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("profile not found: %s", name)
}
if err != nil {
return nil, fmt.Errorf("get profile by name: %w", err)
}
p.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
p.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updated)
return p, nil
}
func (s *Store) UpdateProfile(id int64, field, value string) error {
allowed := map[string]bool{"name": true, "latitude": true, "longitude": true, "timezone": true}
if !allowed[field] {
return fmt.Errorf("invalid field: %s", field)
}
_, err := s.db.Exec(
fmt.Sprintf(`UPDATE profiles SET %s = ?, updated_at = datetime('now') WHERE id = ?`, field),
value, id,
)
return err
}
func (s *Store) DeleteProfile(id int64) error {
_, err := s.db.Exec(`DELETE FROM profiles WHERE id = ?`, id)
return err
}
func (s *Store) ListProfiles() ([]Profile, error) {
rows, err := s.db.Query(`SELECT id, name, latitude, longitude, timezone, created_at, updated_at FROM profiles ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var profiles []Profile
for rows.Next() {
var p Profile
var created, updated string
if err := rows.Scan(&p.ID, &p.Name, &p.Latitude, &p.Longitude, &p.Timezone, &created, &updated); err != nil {
return nil, err
}
p.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
p.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updated)
profiles = append(profiles, p)
}
return profiles, rows.Err()
}

151
internal/store/room.go Normal file
View File

@@ -0,0 +1,151 @@
package store
import (
"database/sql"
"fmt"
"time"
)
type Room struct {
ID int64
ProfileID int64
Name string
AreaSqm float64
CeilingHeightM float64
Floor int
Orientation string
ShadingType string
ShadingFactor float64
Ventilation string
VentilationACH float64
WindowFraction float64
SHGC float64
Insulation string
CreatedAt time.Time
}
// RoomParams holds optional room parameters with defaults.
type RoomParams struct {
CeilingHeightM float64
VentilationACH float64
WindowFraction float64
SHGC float64
}
// DefaultRoomParams returns sensible defaults for room physics parameters.
func DefaultRoomParams() RoomParams {
return RoomParams{
CeilingHeightM: 2.5,
VentilationACH: 0.5,
WindowFraction: 0.15,
SHGC: 0.6,
}
}
func (s *Store) CreateRoom(profileID int64, name string, areaSqm float64, floor int, orientation, shadingType string, shadingFactor float64, ventilation, insulation string, params RoomParams) (*Room, error) {
if shadingType == "" {
shadingType = "none"
}
if shadingFactor == 0 {
shadingFactor = 1.0
}
if ventilation == "" {
ventilation = "natural"
}
if insulation == "" {
insulation = "average"
}
if params.CeilingHeightM == 0 {
params.CeilingHeightM = 2.5
}
if params.VentilationACH == 0 {
params.VentilationACH = 0.5
}
if params.WindowFraction == 0 {
params.WindowFraction = 0.15
}
if params.SHGC == 0 {
params.SHGC = 0.6
}
res, err := s.db.Exec(
`INSERT INTO rooms (profile_id, name, area_sqm, ceiling_height_m, floor, orientation, shading_type, shading_factor, ventilation, ventilation_ach, window_fraction, shgc, insulation) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
profileID, name, areaSqm, params.CeilingHeightM, floor, orientation, shadingType, shadingFactor, ventilation, params.VentilationACH, params.WindowFraction, params.SHGC, insulation,
)
if err != nil {
return nil, fmt.Errorf("create room: %w", err)
}
id, _ := res.LastInsertId()
return s.GetRoom(id)
}
func (s *Store) GetRoom(id int64) (*Room, error) {
r := &Room{}
var created string
err := s.db.QueryRow(
`SELECT id, profile_id, name, area_sqm, ceiling_height_m, floor, orientation, shading_type, shading_factor, ventilation, ventilation_ach, window_fraction, shgc, insulation, created_at FROM rooms WHERE id = ?`, id,
).Scan(&r.ID, &r.ProfileID, &r.Name, &r.AreaSqm, &r.CeilingHeightM, &r.Floor, &r.Orientation, &r.ShadingType, &r.ShadingFactor, &r.Ventilation, &r.VentilationACH, &r.WindowFraction, &r.SHGC, &r.Insulation, &created)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("room not found: %d", id)
}
if err != nil {
return nil, fmt.Errorf("get room: %w", err)
}
r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
return r, nil
}
func (s *Store) ListRooms(profileID int64) ([]Room, error) {
rows, err := s.db.Query(
`SELECT id, profile_id, name, area_sqm, ceiling_height_m, floor, orientation, shading_type, shading_factor, ventilation, ventilation_ach, window_fraction, shgc, insulation, created_at FROM rooms WHERE profile_id = ? ORDER BY name`, profileID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var rooms []Room
for rows.Next() {
var r Room
var created string
if err := rows.Scan(&r.ID, &r.ProfileID, &r.Name, &r.AreaSqm, &r.CeilingHeightM, &r.Floor, &r.Orientation, &r.ShadingType, &r.ShadingFactor, &r.Ventilation, &r.VentilationACH, &r.WindowFraction, &r.SHGC, &r.Insulation, &created); err != nil {
return nil, err
}
r.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created)
rooms = append(rooms, r)
}
return rooms, rows.Err()
}
func (s *Store) UpdateRoom(id int64, field, value string) error {
allowed := map[string]bool{
"name": true, "area_sqm": true, "ceiling_height_m": true, "floor": true, "orientation": true,
"shading_type": true, "shading_factor": true, "ventilation": true, "ventilation_ach": true,
"window_fraction": true, "shgc": true, "insulation": true,
}
if !allowed[field] {
return fmt.Errorf("invalid field: %s", field)
}
_, err := s.db.Exec(
fmt.Sprintf(`UPDATE rooms SET %s = ? WHERE id = ?`, field), value, id,
)
return err
}
func (s *Store) DeleteRoom(id int64) error {
_, err := s.db.Exec(`DELETE FROM rooms WHERE id = ?`, id)
return err
}
// GetRoomACCapacity returns total AC capacity in BTU/h assigned to a room.
func (s *Store) GetRoomACCapacity(roomID int64) (float64, error) {
var total sql.NullFloat64
err := s.db.QueryRow(
`SELECT SUM(a.capacity_btu) FROM ac_units a JOIN ac_room_assignments ar ON a.id = ar.ac_id WHERE ar.room_id = ?`, roomID,
).Scan(&total)
if err != nil {
return 0, err
}
if !total.Valid {
return 0, nil
}
return total.Float64, nil
}

106
internal/store/schema.sql Normal file
View File

@@ -0,0 +1,106 @@
CREATE TABLE IF NOT EXISTS profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
timezone TEXT NOT NULL DEFAULT 'Europe/Berlin',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
name TEXT NOT NULL,
area_sqm REAL NOT NULL,
ceiling_height_m REAL NOT NULL DEFAULT 2.5,
floor INTEGER NOT NULL DEFAULT 0,
orientation TEXT NOT NULL DEFAULT 'N',
shading_type TEXT NOT NULL DEFAULT 'none',
shading_factor REAL NOT NULL DEFAULT 1.0,
ventilation TEXT NOT NULL DEFAULT 'natural',
ventilation_ach REAL NOT NULL DEFAULT 0.5,
window_fraction REAL NOT NULL DEFAULT 0.15,
shgc REAL NOT NULL DEFAULT 0.6,
insulation TEXT NOT NULL DEFAULT 'average',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS occupants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
count INTEGER NOT NULL DEFAULT 1,
activity_level TEXT NOT NULL DEFAULT 'sedentary',
vulnerable INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
name TEXT NOT NULL,
device_type TEXT NOT NULL,
watts_idle REAL NOT NULL DEFAULT 0,
watts_typical REAL NOT NULL DEFAULT 0,
watts_peak REAL NOT NULL DEFAULT 0,
duty_cycle REAL NOT NULL DEFAULT 1.0,
schedule TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS ac_units (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
name TEXT NOT NULL,
ac_type TEXT NOT NULL DEFAULT 'portable',
capacity_btu REAL NOT NULL,
has_dehumidify INTEGER NOT NULL DEFAULT 0,
efficiency_eer REAL NOT NULL DEFAULT 10.0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS ac_room_assignments (
ac_id INTEGER NOT NULL REFERENCES ac_units(id) ON DELETE CASCADE,
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
PRIMARY KEY (ac_id, room_id)
);
CREATE TABLE IF NOT EXISTS forecasts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
timestamp TEXT NOT NULL,
temperature_c REAL,
humidity_pct REAL,
wind_speed_ms REAL,
cloud_cover_pct REAL,
precipitation_mm REAL,
sunshine_min REAL,
pressure_hpa REAL,
dew_point_c REAL,
apparent_temp_c REAL,
condition TEXT,
source TEXT NOT NULL,
fetched_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(profile_id, timestamp, source)
);
CREATE TABLE IF NOT EXISTS warnings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
warning_id TEXT NOT NULL UNIQUE,
event_type TEXT,
severity TEXT,
headline TEXT,
description TEXT,
instruction TEXT,
onset TEXT,
expires TEXT,
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS toggles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
name TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 0,
activated_at TEXT
);

45
internal/store/store.go Normal file
View File

@@ -0,0 +1,45 @@
package store
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
// Store wraps a SQLite database connection.
type Store struct {
db *sql.DB
}
// New opens (or creates) a SQLite database and runs migrations.
func New(dsn string) (*Store, error) {
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
db.Close()
return nil, fmt.Errorf("set WAL mode: %w", err)
}
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
db.Close()
return nil, fmt.Errorf("enable foreign keys: %w", err)
}
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return s, nil
}
// Close closes the database connection.
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) migrate() error {
_, err := s.db.Exec(schemaSQL)
return err
}

View File

@@ -0,0 +1,496 @@
package store
import (
"testing"
"time"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
s, err := New(":memory:")
if err != nil {
t.Fatalf("failed to create test store: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
func TestNewStore(t *testing.T) {
s := newTestStore(t)
if s == nil {
t.Fatal("store is nil")
}
}
// --- Profile CRUD ---
func TestProfileCRUD(t *testing.T) {
s := newTestStore(t)
p, err := s.CreateProfile("home", 52.52, 13.41, "Europe/Berlin")
if err != nil {
t.Fatalf("CreateProfile: %v", err)
}
if p.Name != "home" || p.Latitude != 52.52 || p.Longitude != 13.41 {
t.Errorf("unexpected profile: %+v", p)
}
got, err := s.GetProfile(p.ID)
if err != nil {
t.Fatalf("GetProfile: %v", err)
}
if got.Name != "home" {
t.Errorf("GetProfile name = %s, want home", got.Name)
}
gotByName, err := s.GetProfileByName("home")
if err != nil {
t.Fatalf("GetProfileByName: %v", err)
}
if gotByName.ID != p.ID {
t.Errorf("GetProfileByName ID = %d, want %d", gotByName.ID, p.ID)
}
if err := s.UpdateProfile(p.ID, "name", "office"); err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
updated, _ := s.GetProfile(p.ID)
if updated.Name != "office" {
t.Errorf("updated name = %s, want office", updated.Name)
}
profiles, err := s.ListProfiles()
if err != nil {
t.Fatalf("ListProfiles: %v", err)
}
if len(profiles) != 1 {
t.Errorf("ListProfiles len = %d, want 1", len(profiles))
}
if err := s.DeleteProfile(p.ID); err != nil {
t.Fatalf("DeleteProfile: %v", err)
}
_, err = s.GetProfile(p.ID)
if err == nil {
t.Error("expected error after delete")
}
}
func TestProfileDuplicateName(t *testing.T) {
s := newTestStore(t)
_, err := s.CreateProfile("home", 52.52, 13.41, "")
if err != nil {
t.Fatal(err)
}
_, err = s.CreateProfile("home", 48.13, 11.58, "")
if err == nil {
t.Error("expected error for duplicate name")
}
}
func TestProfileInvalidField(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
err := s.UpdateProfile(p.ID, "nonexistent", "value")
if err == nil {
t.Error("expected error for invalid field")
}
}
// --- Room CRUD ---
func TestRoomCRUD(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
r, err := s.CreateRoom(p.ID, "office", 15, 3, "S", "shutters", 0.3, "natural", "average", DefaultRoomParams())
if err != nil {
t.Fatalf("CreateRoom: %v", err)
}
if r.Name != "office" || r.AreaSqm != 15 || r.Orientation != "S" || r.ShadingFactor != 0.3 {
t.Errorf("unexpected room: %+v", r)
}
rooms, err := s.ListRooms(p.ID)
if err != nil {
t.Fatalf("ListRooms: %v", err)
}
if len(rooms) != 1 {
t.Errorf("ListRooms len = %d, want 1", len(rooms))
}
if err := s.UpdateRoom(r.ID, "name", "bedroom"); err != nil {
t.Fatalf("UpdateRoom: %v", err)
}
updated, _ := s.GetRoom(r.ID)
if updated.Name != "bedroom" {
t.Errorf("updated name = %s, want bedroom", updated.Name)
}
if err := s.DeleteRoom(r.ID); err != nil {
t.Fatalf("DeleteRoom: %v", err)
}
_, err = s.GetRoom(r.ID)
if err == nil {
t.Error("expected error after delete")
}
}
func TestRoomPhysicsFields(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
params := RoomParams{
CeilingHeightM: 3.0,
VentilationACH: 1.5,
WindowFraction: 0.20,
SHGC: 0.45,
}
r, err := s.CreateRoom(p.ID, "office", 15, 3, "S", "shutters", 0.3, "natural", "average", params)
if err != nil {
t.Fatalf("CreateRoom: %v", err)
}
if r.CeilingHeightM != 3.0 {
t.Errorf("CeilingHeightM = %v, want 3.0", r.CeilingHeightM)
}
if r.VentilationACH != 1.5 {
t.Errorf("VentilationACH = %v, want 1.5", r.VentilationACH)
}
if r.WindowFraction != 0.20 {
t.Errorf("WindowFraction = %v, want 0.20", r.WindowFraction)
}
if r.SHGC != 0.45 {
t.Errorf("SHGC = %v, want 0.45", r.SHGC)
}
}
func TestRoomACCapacity(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams())
// No AC assigned yet
cap, err := s.GetRoomACCapacity(r.ID)
if err != nil {
t.Fatalf("GetRoomACCapacity: %v", err)
}
if cap != 0 {
t.Errorf("expected 0 BTU/h, got %v", cap)
}
// Assign AC
ac, _ := s.CreateACUnit(p.ID, "Portable", "portable", 8000, false, 10)
s.AssignACToRoom(ac.ID, r.ID)
cap, err = s.GetRoomACCapacity(r.ID)
if err != nil {
t.Fatalf("GetRoomACCapacity: %v", err)
}
if cap != 8000 {
t.Errorf("expected 8000 BTU/h, got %v", cap)
}
}
// --- Device CRUD ---
func TestDeviceCRUD(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams())
d, err := s.CreateDevice(r.ID, "Gaming PC", "pc", 65, 200, 450, 1.0)
if err != nil {
t.Fatalf("CreateDevice: %v", err)
}
if d.Name != "Gaming PC" || d.WattsTypical != 200 {
t.Errorf("unexpected device: %+v", d)
}
devices, err := s.ListDevices(r.ID)
if err != nil {
t.Fatalf("ListDevices: %v", err)
}
if len(devices) != 1 {
t.Errorf("ListDevices len = %d, want 1", len(devices))
}
allDevices, err := s.ListAllDevices(p.ID)
if err != nil {
t.Fatalf("ListAllDevices: %v", err)
}
if len(allDevices) != 1 {
t.Errorf("ListAllDevices len = %d, want 1", len(allDevices))
}
if err := s.DeleteDevice(d.ID); err != nil {
t.Fatalf("DeleteDevice: %v", err)
}
_, err = s.GetDevice(d.ID)
if err == nil {
t.Error("expected error after delete")
}
}
// --- Occupant CRUD ---
func TestOccupantCRUD(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams())
o, err := s.CreateOccupant(r.ID, 1, "sedentary", false)
if err != nil {
t.Fatalf("CreateOccupant: %v", err)
}
if o.Count != 1 || o.ActivityLevel != "sedentary" || o.Vulnerable {
t.Errorf("unexpected occupant: %+v", o)
}
oVuln, err := s.CreateOccupant(r.ID, 1, "sleeping", true)
if err != nil {
t.Fatal(err)
}
if !oVuln.Vulnerable {
t.Error("expected vulnerable=true")
}
occupants, err := s.ListOccupants(r.ID)
if err != nil {
t.Fatalf("ListOccupants: %v", err)
}
if len(occupants) != 2 {
t.Errorf("ListOccupants len = %d, want 2", len(occupants))
}
all, err := s.ListAllOccupants(p.ID)
if err != nil {
t.Fatalf("ListAllOccupants: %v", err)
}
if len(all) != 2 {
t.Errorf("ListAllOccupants len = %d, want 2", len(all))
}
if err := s.DeleteOccupant(o.ID); err != nil {
t.Fatal(err)
}
_, err = s.GetOccupant(o.ID)
if err == nil {
t.Error("expected error after delete")
}
}
// --- AC Unit CRUD ---
func TestACUnitCRUD(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams())
ac, err := s.CreateACUnit(p.ID, "Portable", "portable", 8000, true, 10.5)
if err != nil {
t.Fatalf("CreateACUnit: %v", err)
}
if ac.Name != "Portable" || ac.CapacityBTU != 8000 || !ac.HasDehumidify {
t.Errorf("unexpected ac: %+v", ac)
}
if err := s.AssignACToRoom(ac.ID, r.ID); err != nil {
t.Fatalf("AssignACToRoom: %v", err)
}
rooms, err := s.GetACRoomAssignments(ac.ID)
if err != nil {
t.Fatalf("GetACRoomAssignments: %v", err)
}
if len(rooms) != 1 || rooms[0] != r.ID {
t.Errorf("unexpected room assignments: %v", rooms)
}
if err := s.UnassignACFromRoom(ac.ID, r.ID); err != nil {
t.Fatal(err)
}
rooms, _ = s.GetACRoomAssignments(ac.ID)
if len(rooms) != 0 {
t.Error("expected empty after unassign")
}
units, err := s.ListACUnits(p.ID)
if err != nil {
t.Fatal(err)
}
if len(units) != 1 {
t.Errorf("ListACUnits len = %d, want 1", len(units))
}
if err := s.DeleteACUnit(ac.ID); err != nil {
t.Fatal(err)
}
}
// --- Forecast CRUD ---
func TestForecastUpsertAndQuery(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
ts := time.Date(2025, 7, 15, 14, 0, 0, 0, time.UTC)
temp := 35.5
hum := 40.0
f := &Forecast{
ProfileID: p.ID,
Timestamp: ts,
TemperatureC: &temp,
HumidityPct: &hum,
Source: "openmeteo",
}
if err := s.UpsertForecast(f); err != nil {
t.Fatalf("UpsertForecast: %v", err)
}
// Upsert again (should update, not duplicate)
temp2 := 36.0
f.TemperatureC = &temp2
if err := s.UpsertForecast(f); err != nil {
t.Fatalf("UpsertForecast update: %v", err)
}
from := ts.Add(-time.Hour)
to := ts.Add(time.Hour)
forecasts, err := s.GetForecasts(p.ID, from, to, "openmeteo")
if err != nil {
t.Fatalf("GetForecasts: %v", err)
}
if len(forecasts) != 1 {
t.Fatalf("GetForecasts len = %d, want 1", len(forecasts))
}
if *forecasts[0].TemperatureC != 36.0 {
t.Errorf("temperature = %v, want 36.0", *forecasts[0].TemperatureC)
}
// Cleanup
deleted, err := s.CleanupOldForecasts(ts.Add(time.Hour * 24))
if err != nil {
t.Fatal(err)
}
if deleted != 1 {
t.Errorf("deleted = %d, want 1", deleted)
}
}
// --- Warning CRUD ---
func TestWarningUpsertAndQuery(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
now := time.Now().UTC()
w := &Warning{
ProfileID: p.ID,
WarningID: "dwd-heat-001",
EventType: "STARKE HITZE",
Severity: "Severe",
Headline: "Heat warning Berlin",
Description: "Temperatures up to 37C expected",
Instruction: "Stay hydrated",
Onset: now,
Expires: now.Add(48 * time.Hour),
}
if err := s.UpsertWarning(w); err != nil {
t.Fatalf("UpsertWarning: %v", err)
}
// Upsert again (should update)
w.Headline = "Updated heat warning"
if err := s.UpsertWarning(w); err != nil {
t.Fatalf("UpsertWarning update: %v", err)
}
got, err := s.GetWarning("dwd-heat-001")
if err != nil {
t.Fatalf("GetWarning: %v", err)
}
if got.Headline != "Updated heat warning" {
t.Errorf("headline = %s, want 'Updated heat warning'", got.Headline)
}
active, err := s.GetActiveWarnings(p.ID, now)
if err != nil {
t.Fatal(err)
}
if len(active) != 1 {
t.Errorf("active warnings = %d, want 1", len(active))
}
// Check expired don't show
expired, _ := s.GetActiveWarnings(p.ID, now.Add(72*time.Hour))
if len(expired) != 0 {
t.Errorf("expected 0 active warnings after expiry, got %d", len(expired))
}
// Cleanup
deleted, err := s.CleanupExpiredWarnings(now.Add(72 * time.Hour))
if err != nil {
t.Fatal(err)
}
if deleted != 1 {
t.Errorf("deleted = %d, want 1", deleted)
}
}
// --- Toggle ---
func TestToggle(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
if err := s.SetToggle(p.ID, "gaming", true); err != nil {
t.Fatalf("SetToggle: %v", err)
}
toggles, err := s.GetToggles(p.ID)
if err != nil {
t.Fatal(err)
}
if len(toggles) != 1 || !toggles[0].Active || toggles[0].Name != "gaming" {
t.Errorf("unexpected toggles: %+v", toggles)
}
active, err := s.GetActiveToggleNames(p.ID)
if err != nil {
t.Fatal(err)
}
if !active["gaming"] {
t.Error("expected gaming toggle active")
}
if err := s.SetToggle(p.ID, "gaming", false); err != nil {
t.Fatal(err)
}
active, _ = s.GetActiveToggleNames(p.ID)
if active["gaming"] {
t.Error("expected gaming toggle inactive")
}
}
// --- Cascade Delete ---
func TestCascadeDeleteProfile(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams())
s.CreateDevice(r.ID, "PC", "pc", 65, 200, 450, 1.0)
s.CreateOccupant(r.ID, 1, "sedentary", false)
s.CreateACUnit(p.ID, "AC", "portable", 8000, false, 10)
if err := s.DeleteProfile(p.ID); err != nil {
t.Fatal(err)
}
rooms, _ := s.ListRooms(p.ID)
if len(rooms) != 0 {
t.Error("expected rooms deleted on cascade")
}
devices, _ := s.ListDevices(r.ID)
if len(devices) != 0 {
t.Error("expected devices deleted on cascade")
}
}

78
internal/store/toggle.go Normal file
View File

@@ -0,0 +1,78 @@
package store
import "fmt"
type Toggle struct {
ID int64
ProfileID int64
Name string
Active bool
ActivatedAt string
}
func (s *Store) SetToggle(profileID int64, name string, active bool) error {
activeInt := 0
activatedAt := ""
if active {
activeInt = 1
activatedAt = "datetime('now')"
}
if active {
_, err := s.db.Exec(
`INSERT INTO toggles (profile_id, name, active, activated_at) VALUES (?, ?, ?, datetime('now'))
ON CONFLICT DO NOTHING`,
profileID, name, activeInt,
)
if err != nil {
return fmt.Errorf("set toggle: %w", err)
}
_, err = s.db.Exec(
`UPDATE toggles SET active = ?, activated_at = datetime('now') WHERE profile_id = ? AND name = ?`,
activeInt, profileID, name,
)
return err
}
_ = activatedAt
_, err := s.db.Exec(
`UPDATE toggles SET active = 0, activated_at = NULL WHERE profile_id = ? AND name = ?`,
profileID, name,
)
return err
}
func (s *Store) GetToggles(profileID int64) ([]Toggle, error) {
rows, err := s.db.Query(
`SELECT id, profile_id, name, active, COALESCE(activated_at, '') FROM toggles WHERE profile_id = ? ORDER BY name`, profileID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var toggles []Toggle
for rows.Next() {
var t Toggle
var active int
if err := rows.Scan(&t.ID, &t.ProfileID, &t.Name, &active, &t.ActivatedAt); err != nil {
return nil, err
}
t.Active = active != 0
toggles = append(toggles, t)
}
return toggles, rows.Err()
}
func (s *Store) GetActiveToggleNames(profileID int64) (map[string]bool, error) {
toggles, err := s.GetToggles(profileID)
if err != nil {
return nil, err
}
m := make(map[string]bool)
for _, t := range toggles {
if t.Active {
m[t.Name] = true
}
}
return m, nil
}

91
internal/store/warning.go Normal file
View File

@@ -0,0 +1,91 @@
package store
import (
"database/sql"
"fmt"
"time"
)
type Warning struct {
ID int64
ProfileID int64
WarningID string
EventType string
Severity string
Headline string
Description string
Instruction string
Onset time.Time
Expires time.Time
FetchedAt time.Time
}
func (s *Store) UpsertWarning(w *Warning) error {
_, err := s.db.Exec(
`INSERT INTO warnings (profile_id, warning_id, event_type, severity, headline, description, instruction, onset, expires)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(warning_id) DO UPDATE SET
severity=excluded.severity, headline=excluded.headline,
description=excluded.description, instruction=excluded.instruction,
onset=excluded.onset, expires=excluded.expires, fetched_at=datetime('now')`,
w.ProfileID, w.WarningID, w.EventType, w.Severity, w.Headline,
w.Description, w.Instruction,
w.Onset.Format(time.RFC3339), w.Expires.Format(time.RFC3339),
)
return err
}
func (s *Store) GetActiveWarnings(profileID int64, now time.Time) ([]Warning, error) {
rows, err := s.db.Query(
`SELECT id, profile_id, warning_id, event_type, severity, headline, description, instruction, onset, expires, fetched_at
FROM warnings WHERE profile_id = ? AND expires > ? ORDER BY onset`,
profileID, now.Format(time.RFC3339),
)
if err != nil {
return nil, err
}
defer rows.Close()
var warnings []Warning
for rows.Next() {
var w Warning
var onset, expires, fetched string
if err := rows.Scan(&w.ID, &w.ProfileID, &w.WarningID, &w.EventType, &w.Severity, &w.Headline, &w.Description, &w.Instruction, &onset, &expires, &fetched); err != nil {
return nil, err
}
w.Onset, _ = time.Parse(time.RFC3339, onset)
w.Expires, _ = time.Parse(time.RFC3339, expires)
w.FetchedAt, _ = time.Parse("2006-01-02 15:04:05", fetched)
warnings = append(warnings, w)
}
return warnings, rows.Err()
}
func (s *Store) GetWarning(warningID string) (*Warning, error) {
w := &Warning{}
var onset, expires, fetched string
err := s.db.QueryRow(
`SELECT id, profile_id, warning_id, event_type, severity, headline, description, instruction, onset, expires, fetched_at
FROM warnings WHERE warning_id = ?`, warningID,
).Scan(&w.ID, &w.ProfileID, &w.WarningID, &w.EventType, &w.Severity, &w.Headline, &w.Description, &w.Instruction, &onset, &expires, &fetched)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("warning not found: %s", warningID)
}
if err != nil {
return nil, fmt.Errorf("get warning: %w", err)
}
w.Onset, _ = time.Parse(time.RFC3339, onset)
w.Expires, _ = time.Parse(time.RFC3339, expires)
w.FetchedAt, _ = time.Parse("2006-01-02 15:04:05", fetched)
return w, nil
}
func (s *Store) CleanupExpiredWarnings(now time.Time) (int64, error) {
res, err := s.db.Exec(
`DELETE FROM warnings WHERE expires < ?`,
now.Format(time.RFC3339),
)
if err != nil {
return 0, err
}
return res.RowsAffected()
}

View File

@@ -0,0 +1,121 @@
package weather
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
const brightSkyBaseURL = "https://api.brightsky.dev/weather"
// BrightSky implements Provider using the Bright Sky API (DWD MOSMIX wrapper).
type BrightSky struct {
client *http.Client
baseURL string
}
// NewBrightSky creates a new Bright Sky provider.
func NewBrightSky(client *http.Client) *BrightSky {
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
return &BrightSky{client: client, baseURL: brightSkyBaseURL}
}
func (b *BrightSky) Name() string { return "brightsky" }
type brightSkyResponse struct {
Weather []struct {
Timestamp string `json:"timestamp"`
Temperature *float64 `json:"temperature"`
Humidity *float64 `json:"relative_humidity"`
DewPoint *float64 `json:"dew_point"`
CloudCover *float64 `json:"cloud_cover"`
WindSpeed *float64 `json:"wind_speed"`
WindDirection *float64 `json:"wind_direction"`
Precipitation *float64 `json:"precipitation"`
Sunshine *float64 `json:"sunshine"`
PressureMSL *float64 `json:"pressure_msl"`
Condition string `json:"condition"`
} `json:"weather"`
}
func (b *BrightSky) FetchForecast(ctx context.Context, lat, lon float64, timezone string) (*ForecastResponse, error) {
now := time.Now()
params := url.Values{
"lat": {fmt.Sprintf("%.4f", lat)},
"lon": {fmt.Sprintf("%.4f", lon)},
"date": {now.Format("2006-01-02")},
"last_date": {now.Add(72 * time.Hour).Format("2006-01-02")},
}
reqURL := b.baseURL + "?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
resp, err := b.client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch brightsky: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("brightsky returned status %d", resp.StatusCode)
}
var raw brightSkyResponse
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("decode brightsky: %w", err)
}
result := &ForecastResponse{Source: "brightsky"}
for _, w := range raw.Weather {
t, err := time.Parse(time.RFC3339, w.Timestamp)
if err != nil {
continue
}
hf := HourlyForecast{
Timestamp: t,
Condition: w.Condition,
}
if w.Temperature != nil {
hf.TemperatureC = *w.Temperature
}
if w.Humidity != nil {
hf.HumidityPct = *w.Humidity
}
if w.DewPoint != nil {
hf.DewPointC = *w.DewPoint
}
if w.CloudCover != nil {
hf.CloudCoverPct = *w.CloudCover
}
if w.WindSpeed != nil {
hf.WindSpeedMs = *w.WindSpeed / 3.6 // km/h -> m/s
}
if w.WindDirection != nil {
hf.WindDirectionDeg = *w.WindDirection
}
if w.Precipitation != nil {
hf.PrecipitationMm = *w.Precipitation
}
if w.Sunshine != nil {
hf.SunshineMin = *w.Sunshine
}
if w.PressureMSL != nil {
hf.PressureHpa = *w.PressureMSL
}
// BrightSky doesn't provide IsDay — approximate from hour
hour := t.Hour()
hf.IsDay = hour >= 6 && hour < 21
result.Hourly = append(result.Hourly, hf)
}
return result, nil
}

View File

@@ -0,0 +1,92 @@
package weather
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
const brightSkyTestJSON = `{
"weather": [
{
"timestamp": "2025-07-15T14:00:00+02:00",
"temperature": 34.5,
"relative_humidity": 40,
"dew_point": 19.5,
"cloud_cover": 15,
"wind_speed": 10.8,
"wind_direction": 220,
"precipitation": 0,
"sunshine": 55,
"pressure_msl": 1015.2,
"condition": "dry"
},
{
"timestamp": "2025-07-15T15:00:00+02:00",
"temperature": 35.1,
"relative_humidity": 38,
"dew_point": 19.0,
"cloud_cover": 10,
"wind_speed": 7.2,
"wind_direction": 210,
"precipitation": 0,
"sunshine": 60,
"pressure_msl": 1015.0,
"condition": "dry"
}
]
}`
func TestBrightSkyFetchForecast(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(brightSkyTestJSON))
}))
defer srv.Close()
bs := NewBrightSky(srv.Client())
bs.baseURL = srv.URL
resp, err := bs.FetchForecast(context.Background(), 52.52, 13.41, "Europe/Berlin")
if err != nil {
t.Fatalf("FetchForecast: %v", err)
}
if resp.Source != "brightsky" {
t.Errorf("Source = %s, want brightsky", resp.Source)
}
if len(resp.Hourly) != 2 {
t.Fatalf("Hourly len = %d, want 2", len(resp.Hourly))
}
if resp.Hourly[0].TemperatureC != 34.5 {
t.Errorf("temp = %v, want 34.5", resp.Hourly[0].TemperatureC)
}
// wind_speed is km/h in brightsky, converted to m/s
expectedWindMs := 10.8 / 3.6
if diff := resp.Hourly[0].WindSpeedMs - expectedWindMs; diff > 0.01 || diff < -0.01 {
t.Errorf("WindSpeedMs = %v, want %v", resp.Hourly[0].WindSpeedMs, expectedWindMs)
}
if resp.Hourly[0].Condition != "dry" {
t.Errorf("Condition = %s, want dry", resp.Hourly[0].Condition)
}
// 14:00 should be daytime
if !resp.Hourly[0].IsDay {
t.Error("expected IsDay=true for hour 14")
}
}
func TestBrightSkyServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
defer srv.Close()
bs := NewBrightSky(srv.Client())
bs.baseURL = srv.URL
_, err := bs.FetchForecast(context.Background(), 52.52, 13.41, "Europe/Berlin")
if err == nil {
t.Error("expected error for 502 response")
}
}

116
internal/weather/dwd_wfs.go Normal file
View File

@@ -0,0 +1,116 @@
package weather
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
const dwdWFSBaseURL = "https://maps.dwd.de/geoserver/dwd/ows"
// DWDWFS implements WarningProvider using the DWD WFS GeoServer.
type DWDWFS struct {
client *http.Client
baseURL string
}
// NewDWDWFS creates a new DWD WFS warning provider.
func NewDWDWFS(client *http.Client) *DWDWFS {
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
return &DWDWFS{client: client, baseURL: dwdWFSBaseURL}
}
func (d *DWDWFS) Name() string { return "dwd_wfs" }
type dwdGeoJSON struct {
Features []struct {
Properties struct {
ECII int `json:"EC_II"`
Severity string `json:"SEVERITY"`
Headline string `json:"HEADLINE"`
Description string `json:"DESCRIPTION"`
Instruction string `json:"INSTRUCTION"`
Onset string `json:"ONSET"`
Expires string `json:"EXPIRES"`
Identifier string `json:"IDENTIFIER"`
Event string `json:"EVENT"`
} `json:"properties"`
} `json:"features"`
}
func (d *DWDWFS) FetchWarnings(ctx context.Context, lat, lon float64) ([]Warning, error) {
// BBOX filter: ~50km radius around user location
const bboxBuffer = 0.5 // roughly 50km at mid-latitudes
cql := fmt.Sprintf("EC_II IN(247,248) AND BBOX(THE_GEOM,%.4f,%.4f,%.4f,%.4f)",
lon-bboxBuffer, lat-bboxBuffer, lon+bboxBuffer, lat+bboxBuffer)
params := url.Values{
"SERVICE": {"WFS"},
"VERSION": {"2.0.0"},
"REQUEST": {"GetFeature"},
"typeName": {"dwd:Warnungen_Gemeinden"},
"outputFormat": {"application/json"},
"CQL_FILTER": {cql},
}
reqURL := d.baseURL + "?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
resp, err := d.client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch dwd wfs: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("dwd wfs returned status %d", resp.StatusCode)
}
var raw dwdGeoJSON
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("decode dwd wfs: %w", err)
}
seen := make(map[string]bool)
var warnings []Warning
for _, f := range raw.Features {
p := f.Properties
if seen[p.Identifier] {
continue
}
seen[p.Identifier] = true
var eventType string
switch p.ECII {
case 247:
eventType = "STARKE HITZE"
case 248:
eventType = "EXTREME HITZE"
default:
eventType = p.Event
}
onset, _ := time.Parse(time.RFC3339, p.Onset)
expires, _ := time.Parse(time.RFC3339, p.Expires)
warnings = append(warnings, Warning{
ID: p.Identifier,
EventType: eventType,
Severity: p.Severity,
Headline: p.Headline,
Description: p.Description,
Instruction: p.Instruction,
Onset: onset,
Expires: expires,
})
}
return warnings, nil
}

View File

@@ -0,0 +1,127 @@
package weather
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
const dwdWFSTestJSON = `{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"EC_II": 247,
"SEVERITY": "Severe",
"HEADLINE": "Amtliche WARNUNG vor HITZE",
"DESCRIPTION": "Es tritt eine starke Wärmebelastung auf.",
"INSTRUCTION": "Trinken Sie ausreichend.",
"ONSET": "2025-07-15T11:00:00Z",
"EXPIRES": "2025-07-16T19:00:00Z",
"IDENTIFIER": "dwd-heat-247-001",
"EVENT": "HITZE"
}
},
{
"type": "Feature",
"properties": {
"EC_II": 248,
"SEVERITY": "Extreme",
"HEADLINE": "Amtliche WARNUNG vor EXTREMER HITZE",
"DESCRIPTION": "Es tritt eine extreme Wärmebelastung auf.",
"INSTRUCTION": "Vermeiden Sie körperliche Anstrengung.",
"ONSET": "2025-07-16T11:00:00Z",
"EXPIRES": "2025-07-17T19:00:00Z",
"IDENTIFIER": "dwd-heat-248-001",
"EVENT": "HITZE"
}
},
{
"type": "Feature",
"properties": {
"EC_II": 247,
"SEVERITY": "Severe",
"HEADLINE": "Duplicate warning",
"DESCRIPTION": "Should be deduplicated",
"INSTRUCTION": "",
"ONSET": "2025-07-15T11:00:00Z",
"EXPIRES": "2025-07-16T19:00:00Z",
"IDENTIFIER": "dwd-heat-247-001",
"EVENT": "HITZE"
}
}
]
}`
func TestDWDWFSFetchWarnings(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(dwdWFSTestJSON))
}))
defer srv.Close()
dwd := NewDWDWFS(srv.Client())
dwd.baseURL = srv.URL
warnings, err := dwd.FetchWarnings(context.Background(), 52.52, 13.41)
if err != nil {
t.Fatalf("FetchWarnings: %v", err)
}
// Should deduplicate: 3 features, 2 unique identifiers
if len(warnings) != 2 {
t.Fatalf("warnings len = %d, want 2 (deduplicated)", len(warnings))
}
w0 := warnings[0]
if w0.ID != "dwd-heat-247-001" {
t.Errorf("ID = %s, want dwd-heat-247-001", w0.ID)
}
if w0.EventType != "STARKE HITZE" {
t.Errorf("EventType = %s, want STARKE HITZE", w0.EventType)
}
if w0.Severity != "Severe" {
t.Errorf("Severity = %s, want Severe", w0.Severity)
}
w1 := warnings[1]
if w1.EventType != "EXTREME HITZE" {
t.Errorf("EventType = %s, want EXTREME HITZE", w1.EventType)
}
}
func TestDWDWFSServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()
dwd := NewDWDWFS(srv.Client())
dwd.baseURL = srv.URL
_, err := dwd.FetchWarnings(context.Background(), 52.52, 13.41)
if err == nil {
t.Error("expected error for 503 response")
}
}
func TestDWDWFSEmptyResponse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"type": "FeatureCollection", "features": []}`))
}))
defer srv.Close()
dwd := NewDWDWFS(srv.Client())
dwd.baseURL = srv.URL
warnings, err := dwd.FetchWarnings(context.Background(), 52.52, 13.41)
if err != nil {
t.Fatalf("FetchWarnings: %v", err)
}
if len(warnings) != 0 {
t.Errorf("warnings len = %d, want 0", len(warnings))
}
}

View File

@@ -0,0 +1,167 @@
package weather
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
const openMeteoBaseURL = "https://api.open-meteo.com/v1/forecast"
// OpenMeteo implements Provider using the Open-Meteo API.
type OpenMeteo struct {
client *http.Client
baseURL string
}
// NewOpenMeteo creates a new Open-Meteo provider.
func NewOpenMeteo(client *http.Client) *OpenMeteo {
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
return &OpenMeteo{client: client, baseURL: openMeteoBaseURL}
}
func (o *OpenMeteo) Name() string { return "openmeteo" }
type openMeteoResponse struct {
Hourly struct {
Time []string `json:"time"`
Temperature2m []float64 `json:"temperature_2m"`
ApparentTemperature []float64 `json:"apparent_temperature"`
RelativeHumidity2m []float64 `json:"relative_humidity_2m"`
DewPoint2m []float64 `json:"dew_point_2m"`
CloudCover []float64 `json:"cloud_cover"`
WindSpeed10m []float64 `json:"wind_speed_10m"`
WindDirection10m []float64 `json:"wind_direction_10m"`
Precipitation []float64 `json:"precipitation"`
SunshineDuration []float64 `json:"sunshine_duration"`
ShortwaveRadiation []float64 `json:"shortwave_radiation"`
SurfacePressure []float64 `json:"surface_pressure"`
IsDay []int `json:"is_day"`
} `json:"hourly"`
Daily struct {
Time []string `json:"time"`
Temperature2mMax []float64 `json:"temperature_2m_max"`
Temperature2mMin []float64 `json:"temperature_2m_min"`
ApparentTemperatureMax []float64 `json:"apparent_temperature_max"`
Sunrise []string `json:"sunrise"`
Sunset []string `json:"sunset"`
} `json:"daily"`
}
func (o *OpenMeteo) FetchForecast(ctx context.Context, lat, lon float64, timezone string) (*ForecastResponse, error) {
if timezone == "" {
timezone = "Europe/Berlin"
}
params := url.Values{
"latitude": {fmt.Sprintf("%.4f", lat)},
"longitude": {fmt.Sprintf("%.4f", lon)},
"hourly": {"temperature_2m,apparent_temperature,relative_humidity_2m,dew_point_2m,cloud_cover,wind_speed_10m,wind_direction_10m,precipitation,sunshine_duration,shortwave_radiation,surface_pressure,is_day"},
"daily": {"temperature_2m_max,temperature_2m_min,apparent_temperature_max,sunrise,sunset"},
"wind_speed_unit": {"ms"},
"timezone": {timezone},
"forecast_days": {"3"},
}
reqURL := o.baseURL + "?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
resp, err := o.client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch openmeteo: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("openmeteo returned status %d", resp.StatusCode)
}
var raw openMeteoResponse
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("decode openmeteo: %w", err)
}
loc, _ := time.LoadLocation(timezone)
result := &ForecastResponse{Source: "openmeteo"}
for i, ts := range raw.Hourly.Time {
t, err := time.ParseInLocation("2006-01-02T15:04", ts, loc)
if err != nil {
continue
}
hf := HourlyForecast{
Timestamp: t,
Condition: "",
}
if i < len(raw.Hourly.Temperature2m) {
hf.TemperatureC = raw.Hourly.Temperature2m[i]
}
if i < len(raw.Hourly.ApparentTemperature) {
hf.ApparentTempC = raw.Hourly.ApparentTemperature[i]
}
if i < len(raw.Hourly.RelativeHumidity2m) {
hf.HumidityPct = raw.Hourly.RelativeHumidity2m[i]
}
if i < len(raw.Hourly.DewPoint2m) {
hf.DewPointC = raw.Hourly.DewPoint2m[i]
}
if i < len(raw.Hourly.CloudCover) {
hf.CloudCoverPct = raw.Hourly.CloudCover[i]
}
if i < len(raw.Hourly.WindSpeed10m) {
hf.WindSpeedMs = raw.Hourly.WindSpeed10m[i]
}
if i < len(raw.Hourly.WindDirection10m) {
hf.WindDirectionDeg = raw.Hourly.WindDirection10m[i]
}
if i < len(raw.Hourly.Precipitation) {
hf.PrecipitationMm = raw.Hourly.Precipitation[i]
}
if i < len(raw.Hourly.SunshineDuration) {
hf.SunshineMin = raw.Hourly.SunshineDuration[i] / 60.0 // seconds -> minutes
}
if i < len(raw.Hourly.ShortwaveRadiation) {
hf.ShortwaveRadW = raw.Hourly.ShortwaveRadiation[i]
}
if i < len(raw.Hourly.SurfacePressure) {
hf.PressureHpa = raw.Hourly.SurfacePressure[i]
}
if i < len(raw.Hourly.IsDay) {
hf.IsDay = raw.Hourly.IsDay[i] == 1
}
result.Hourly = append(result.Hourly, hf)
}
for i, ds := range raw.Daily.Time {
t, err := time.ParseInLocation("2006-01-02", ds, loc)
if err != nil {
continue
}
df := DailyForecast{Date: t}
if i < len(raw.Daily.Temperature2mMax) {
df.TempMaxC = raw.Daily.Temperature2mMax[i]
}
if i < len(raw.Daily.Temperature2mMin) {
df.TempMinC = raw.Daily.Temperature2mMin[i]
}
if i < len(raw.Daily.ApparentTemperatureMax) {
df.ApparentTempMaxC = raw.Daily.ApparentTemperatureMax[i]
}
if i < len(raw.Daily.Sunrise) {
df.Sunrise, _ = time.ParseInLocation("2006-01-02T15:04", raw.Daily.Sunrise[i], loc)
}
if i < len(raw.Daily.Sunset) {
df.Sunset, _ = time.ParseInLocation("2006-01-02T15:04", raw.Daily.Sunset[i], loc)
}
result.Daily = append(result.Daily, df)
}
return result, nil
}

View File

@@ -0,0 +1,88 @@
package weather
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
const openMeteoTestJSON = `{
"hourly": {
"time": ["2025-07-15T00:00", "2025-07-15T01:00", "2025-07-15T02:00"],
"temperature_2m": [22.5, 21.8, 21.0],
"apparent_temperature": [23.1, 22.4, 21.5],
"relative_humidity_2m": [65, 68, 72],
"dew_point_2m": [15.5, 15.8, 16.0],
"cloud_cover": [20, 30, 45],
"wind_speed_10m": [3.5, 2.8, 2.1],
"wind_direction_10m": [180, 190, 200],
"precipitation": [0, 0, 0],
"sunshine_duration": [3600, 3000, 0],
"shortwave_radiation": [0, 0, 0],
"surface_pressure": [1013, 1013, 1012],
"is_day": [0, 0, 0]
},
"daily": {
"time": ["2025-07-15"],
"temperature_2m_max": [35.5],
"temperature_2m_min": [20.1],
"apparent_temperature_max": [37.2],
"sunrise": ["2025-07-15T05:15"],
"sunset": ["2025-07-15T21:30"]
}
}`
func TestOpenMeteoFetchForecast(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(openMeteoTestJSON))
}))
defer srv.Close()
om := NewOpenMeteo(srv.Client())
om.baseURL = srv.URL
resp, err := om.FetchForecast(context.Background(), 52.52, 13.41, "Europe/Berlin")
if err != nil {
t.Fatalf("FetchForecast: %v", err)
}
if resp.Source != "openmeteo" {
t.Errorf("Source = %s, want openmeteo", resp.Source)
}
if len(resp.Hourly) != 3 {
t.Fatalf("Hourly len = %d, want 3", len(resp.Hourly))
}
if resp.Hourly[0].TemperatureC != 22.5 {
t.Errorf("Hourly[0].TemperatureC = %v, want 22.5", resp.Hourly[0].TemperatureC)
}
if resp.Hourly[0].ApparentTempC != 23.1 {
t.Errorf("Hourly[0].ApparentTempC = %v, want 23.1", resp.Hourly[0].ApparentTempC)
}
if resp.Hourly[0].SunshineMin != 60.0 {
t.Errorf("Hourly[0].SunshineMin = %v, want 60 (3600s / 60)", resp.Hourly[0].SunshineMin)
}
if len(resp.Daily) != 1 {
t.Fatalf("Daily len = %d, want 1", len(resp.Daily))
}
if resp.Daily[0].TempMaxC != 35.5 {
t.Errorf("Daily[0].TempMaxC = %v, want 35.5", resp.Daily[0].TempMaxC)
}
}
func TestOpenMeteoServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
om := NewOpenMeteo(srv.Client())
om.baseURL = srv.URL
_, err := om.FetchForecast(context.Background(), 52.52, 13.41, "Europe/Berlin")
if err == nil {
t.Error("expected error for 500 response")
}
}

View File

@@ -0,0 +1,22 @@
package weather
import "context"
// Provider is the interface for weather data sources.
type Provider interface {
// FetchForecast retrieves hourly + daily forecasts for a location.
// The timezone parameter (e.g. "Europe/Berlin") controls the API's time axis.
FetchForecast(ctx context.Context, lat, lon float64, timezone string) (*ForecastResponse, error)
// Name returns the provider identifier (e.g., "openmeteo", "brightsky").
Name() string
}
// WarningProvider is the interface for weather warning sources.
type WarningProvider interface {
// FetchWarnings retrieves active heat warnings near the given location.
FetchWarnings(ctx context.Context, lat, lon float64) ([]Warning, error)
// Name returns the provider identifier.
Name() string
}

50
internal/weather/types.go Normal file
View File

@@ -0,0 +1,50 @@
package weather
import "time"
// HourlyForecast represents a single hour's weather data.
type HourlyForecast struct {
Timestamp time.Time
TemperatureC float64
ApparentTempC float64
HumidityPct float64
DewPointC float64
CloudCoverPct float64
WindSpeedMs float64
WindDirectionDeg float64
PrecipitationMm float64
SunshineMin float64
ShortwaveRadW float64
PressureHpa float64
IsDay bool
Condition string
}
// DailyForecast represents a single day's summary data.
type DailyForecast struct {
Date time.Time
TempMaxC float64
TempMinC float64
ApparentTempMaxC float64
Sunrise time.Time
Sunset time.Time
}
// ForecastResponse holds the full forecast response from a provider.
type ForecastResponse struct {
Hourly []HourlyForecast
Daily []DailyForecast
Source string
}
// Warning represents a weather warning.
type Warning struct {
ID string
EventType string
Severity string
Headline string
Description string
Instruction string
Onset time.Time
Expires time.Time
}

1055
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "heatwave-autopilot",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"@tailwindcss/cli": "^4.1.18",
"tailwindcss": "^4.1.18"
}
}

3
tailwind/input.css Normal file
View File

@@ -0,0 +1,3 @@
@import "tailwindcss";
@source "../internal/report/templates/*.tmpl";
@source "../internal/cli/templates/*.tmpl";