From 1c9db023340f086d926d350855c9eba1d05da6d9 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Mon, 9 Feb 2026 10:39:00 +0100 Subject: [PATCH] feat: add web UI with full CRUD setup page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 2 + Makefile | 16 + cmd/heatwave/main.go | 13 + go.mod | 24 + go.sum | 66 ++ internal/action/action.go | 68 ++ internal/action/library.go | 23 + internal/action/library_test.go | 41 + internal/action/selector.go | 127 ++ internal/action/selector_test.go | 100 ++ internal/action/templates/actions.yaml | 97 ++ internal/action/timeline.go | 30 + internal/action/timeline_test.go | 50 + internal/cli/ac.go | 110 ++ internal/cli/budget.go | 120 ++ internal/cli/device.go | 106 ++ internal/cli/forecast.go | 165 +++ internal/cli/heatplan.go | 158 +++ internal/cli/occupant.go | 97 ++ internal/cli/plan.go | 165 +++ internal/cli/profile.go | 106 ++ internal/cli/report.go | 331 ++++++ internal/cli/room.go | 119 ++ internal/cli/root.go | 113 ++ internal/cli/summary.go | 117 ++ internal/cli/templates/setup.html.tmpl | 542 +++++++++ internal/cli/toggle.go | 34 + internal/cli/version.go | 20 + internal/cli/web.go | 57 + internal/cli/web_handlers.go | 473 ++++++++ internal/config/config.go | 62 + internal/heat/budget.go | 78 ++ internal/heat/budget_test.go | 129 ++ internal/heat/external_gains.go | 103 ++ internal/heat/external_gains_test.go | 130 ++ internal/heat/internal_gains.go | 93 ++ internal/heat/internal_gains_test.go | 96 ++ internal/heat/units.go | 14 + internal/heat/units_test.go | 62 + internal/llm/anthropic.go | 106 ++ internal/llm/llm_test.go | 121 ++ internal/llm/noop.go | 12 + internal/llm/ollama.go | 99 ++ internal/llm/openai.go | 103 ++ internal/llm/prompt.go | 98 ++ internal/llm/prompt_test.go | 93 ++ internal/llm/provider.go | 71 ++ internal/report/data.go | 73 ++ internal/report/generator.go | 155 +++ internal/report/generator_test.go | 176 +++ internal/report/sample_test.go | 102 ++ internal/report/templates/dashboard.html.tmpl | 166 +++ internal/risk/analyzer.go | 167 +++ internal/risk/analyzer_test.go | 166 +++ internal/risk/thresholds.go | 21 + internal/static/embed.go | 6 + internal/static/tailwind.css | 2 + internal/store/ac.go | 133 +++ internal/store/device.go | 113 ++ internal/store/forecast.go | 96 ++ internal/store/migrations.go | 6 + internal/store/occupant.go | 108 ++ internal/store/profile.go | 103 ++ internal/store/room.go | 151 +++ internal/store/schema.sql | 106 ++ internal/store/store.go | 45 + internal/store/store_test.go | 496 ++++++++ internal/store/toggle.go | 78 ++ internal/store/warning.go | 91 ++ internal/weather/brightsky.go | 121 ++ internal/weather/brightsky_test.go | 92 ++ internal/weather/dwd_wfs.go | 116 ++ internal/weather/dwd_wfs_test.go | 127 ++ internal/weather/openmeteo.go | 167 +++ internal/weather/openmeteo_test.go | 88 ++ internal/weather/provider.go | 22 + internal/weather/types.go | 50 + package-lock.json | 1055 +++++++++++++++++ package.json | 17 + tailwind/input.css | 3 + 80 files changed, 9378 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 cmd/heatwave/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/action/action.go create mode 100644 internal/action/library.go create mode 100644 internal/action/library_test.go create mode 100644 internal/action/selector.go create mode 100644 internal/action/selector_test.go create mode 100644 internal/action/templates/actions.yaml create mode 100644 internal/action/timeline.go create mode 100644 internal/action/timeline_test.go create mode 100644 internal/cli/ac.go create mode 100644 internal/cli/budget.go create mode 100644 internal/cli/device.go create mode 100644 internal/cli/forecast.go create mode 100644 internal/cli/heatplan.go create mode 100644 internal/cli/occupant.go create mode 100644 internal/cli/plan.go create mode 100644 internal/cli/profile.go create mode 100644 internal/cli/report.go create mode 100644 internal/cli/room.go create mode 100644 internal/cli/root.go create mode 100644 internal/cli/summary.go create mode 100644 internal/cli/templates/setup.html.tmpl create mode 100644 internal/cli/toggle.go create mode 100644 internal/cli/version.go create mode 100644 internal/cli/web.go create mode 100644 internal/cli/web_handlers.go create mode 100644 internal/config/config.go create mode 100644 internal/heat/budget.go create mode 100644 internal/heat/budget_test.go create mode 100644 internal/heat/external_gains.go create mode 100644 internal/heat/external_gains_test.go create mode 100644 internal/heat/internal_gains.go create mode 100644 internal/heat/internal_gains_test.go create mode 100644 internal/heat/units.go create mode 100644 internal/heat/units_test.go create mode 100644 internal/llm/anthropic.go create mode 100644 internal/llm/llm_test.go create mode 100644 internal/llm/noop.go create mode 100644 internal/llm/ollama.go create mode 100644 internal/llm/openai.go create mode 100644 internal/llm/prompt.go create mode 100644 internal/llm/prompt_test.go create mode 100644 internal/llm/provider.go create mode 100644 internal/report/data.go create mode 100644 internal/report/generator.go create mode 100644 internal/report/generator_test.go create mode 100644 internal/report/sample_test.go create mode 100644 internal/report/templates/dashboard.html.tmpl create mode 100644 internal/risk/analyzer.go create mode 100644 internal/risk/analyzer_test.go create mode 100644 internal/risk/thresholds.go create mode 100644 internal/static/embed.go create mode 100644 internal/static/tailwind.css create mode 100644 internal/store/ac.go create mode 100644 internal/store/device.go create mode 100644 internal/store/forecast.go create mode 100644 internal/store/migrations.go create mode 100644 internal/store/occupant.go create mode 100644 internal/store/profile.go create mode 100644 internal/store/room.go create mode 100644 internal/store/schema.sql create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 internal/store/toggle.go create mode 100644 internal/store/warning.go create mode 100644 internal/weather/brightsky.go create mode 100644 internal/weather/brightsky_test.go create mode 100644 internal/weather/dwd_wfs.go create mode 100644 internal/weather/dwd_wfs_test.go create mode 100644 internal/weather/openmeteo.go create mode 100644 internal/weather/openmeteo_test.go create mode 100644 internal/weather/provider.go create mode 100644 internal/weather/types.go create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tailwind/input.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50b5631 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bin/ +node_modules/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0d88c6d --- /dev/null +++ b/Makefile @@ -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 diff --git a/cmd/heatwave/main.go b/cmd/heatwave/main.go new file mode 100644 index 0000000..ad181be --- /dev/null +++ b/cmd/heatwave/main.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..226d7f1 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..365ea9f --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/action/action.go b/internal/action/action.go new file mode 100644 index 0000000..04bc958 --- /dev/null +++ b/internal/action/action.go @@ -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 +} diff --git a/internal/action/library.go b/internal/action/library.go new file mode 100644 index 0000000..004a82e --- /dev/null +++ b/internal/action/library.go @@ -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 +} diff --git a/internal/action/library_test.go b/internal/action/library_test.go new file mode 100644 index 0000000..5910538 --- /dev/null +++ b/internal/action/library_test.go @@ -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]) + } +} diff --git a/internal/action/selector.go b/internal/action/selector.go new file mode 100644 index 0000000..7d1cfa2 --- /dev/null +++ b/internal/action/selector.go @@ -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 + } +} diff --git a/internal/action/selector_test.go b/internal/action/selector_test.go new file mode 100644 index 0000000..0417ca0 --- /dev/null +++ b/internal/action/selector_test.go @@ -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) + } +} diff --git a/internal/action/templates/actions.yaml b/internal/action/templates/actions.yaml new file mode 100644 index 0000000..1bf8230 --- /dev/null +++ b/internal/action/templates/actions.yaml @@ -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 diff --git a/internal/action/timeline.go b/internal/action/timeline.go new file mode 100644 index 0000000..a9a9a26 --- /dev/null +++ b/internal/action/timeline.go @@ -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 +} diff --git a/internal/action/timeline_test.go b/internal/action/timeline_test.go new file mode 100644 index 0000000..eb04c2e --- /dev/null +++ b/internal/action/timeline_test.go @@ -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) + } + } +} diff --git a/internal/cli/ac.go b/internal/cli/ac.go new file mode 100644 index 0000000..35c9477 --- /dev/null +++ b/internal/cli/ac.go @@ -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 ", + 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 ", + 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 ", + 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) +} diff --git a/internal/cli/budget.go b/internal/cli/budget.go new file mode 100644 index 0000000..381046d --- /dev/null +++ b/internal/cli/budget.go @@ -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, + }) +} diff --git a/internal/cli/device.go b/internal/cli/device.go new file mode 100644 index 0000000..6ea95ba --- /dev/null +++ b/internal/cli/device.go @@ -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 ", + 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 ", + 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 ", + 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) +} diff --git a/internal/cli/forecast.go b/internal/cli/forecast.go new file mode 100644 index 0000000..8d3e24e --- /dev/null +++ b/internal/cli/forecast.go @@ -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 +} diff --git a/internal/cli/heatplan.go b/internal/cli/heatplan.go new file mode 100644 index 0000000..ca7018c --- /dev/null +++ b/internal/cli/heatplan.go @@ -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) +} diff --git a/internal/cli/occupant.go b/internal/cli/occupant.go new file mode 100644 index 0000000..771ba66 --- /dev/null +++ b/internal/cli/occupant.go @@ -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 ", + 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 ", + 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) +} diff --git a/internal/cli/plan.go b/internal/cli/plan.go new file mode 100644 index 0000000..f29bede --- /dev/null +++ b/internal/cli/plan.go @@ -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 +} diff --git a/internal/cli/profile.go b/internal/cli/profile.go new file mode 100644 index 0000000..399f6a1 --- /dev/null +++ b/internal/cli/profile.go @@ -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 ", + 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 ", + 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) +} diff --git a/internal/cli/report.go b/internal/cli/report.go new file mode 100644 index 0000000..630f8b4 --- /dev/null +++ b/internal/cli/report.go @@ -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() +} diff --git a/internal/cli/room.go b/internal/cli/room.go new file mode 100644 index 0000000..0ab0c35 --- /dev/null +++ b/internal/cli/room.go @@ -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 ", + 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 ", + 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 ", + 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) +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..52d20b7 --- /dev/null +++ b/internal/cli/root.go @@ -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 --lat --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() + } +} diff --git a/internal/cli/summary.go b/internal/cli/summary.go new file mode 100644 index 0000000..383a9fe --- /dev/null +++ b/internal/cli/summary.go @@ -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) +} diff --git a/internal/cli/templates/setup.html.tmpl b/internal/cli/templates/setup.html.tmpl new file mode 100644 index 0000000..27faaa0 --- /dev/null +++ b/internal/cli/templates/setup.html.tmpl @@ -0,0 +1,542 @@ + + + + + +Heatwave Setup + + + + + + +
+ +{{if .Flash}} +
+ {{.Flash}} +
+{{end}} + +

Setup

+

+ Active profile: {{if .Profile}}{{.Profile.Name}}{{else}}(none){{end}} +

+ + + +{{template "profiles" .}} +{{template "rooms" .}} +{{template "devices" .}} +{{template "occupants" .}} +{{template "ac_units" .}} +{{template "toggles" .}} +{{template "forecast" .}} + +
+

Heatwave Autopilot

+
+ +
+ + + +{{define "profiles"}} +
+

Profiles

+ {{if .Profiles}} +
+ + + + + + + + + + + + {{range .Profiles}} + + + + + + + + {{end}} + +
NameLatitudeLongitudeTimezone
{{.Name}}{{printf "%.4f" .Latitude}}{{printf "%.4f" .Longitude}}{{.Timezone}} +
+ +
+
+
+ {{else}} +

No profiles yet.

+ {{end}} + +
+

Add Profile

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+{{end}} + +{{define "rooms"}} +
+

Rooms

+ {{if not .Profile}} +

Create a profile first.

+ {{else}} + {{if .Rooms}} +
+ + + + + + + + + + + + + {{range .Rooms}} + + + + + + + + + {{end}} + +
NameArea (m²)FloorOrientationCeiling (m)
{{.Name}}{{printf "%.1f" .AreaSqm}}{{.Floor}}{{.Orientation}}{{printf "%.2f" .CeilingHeightM}} +
+ +
+
+
+ {{else}} +

No rooms yet.

+ {{end}} + +
+

Add Room

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ {{end}} +
+{{end}} + +{{define "devices"}} +
+

Devices

+ {{if not .Profile}} +

Create a profile first.

+ {{else}} + {{if .Devices}} +
+ + + + + + + + + + + + + + + {{range .Devices}} + + + + + + + + + + + {{end}} + +
NameTypeRoomIdle (W)Typical (W)Peak (W)Duty
{{.Name}}{{.DeviceType}}{{.RoomID}}{{printf "%.0f" .WattsIdle}}{{printf "%.0f" .WattsTypical}}{{printf "%.0f" .WattsPeak}}{{printf "%.0f%%" (mul .DutyCycle 100)}} +
+ +
+
+
+ {{else}} +

No devices yet.

+ {{end}} + + {{if .Rooms}} +
+

Add Device

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ {{else}} +

Add a room first to create devices.

+ {{end}} + {{end}} +
+{{end}} + +{{define "occupants"}} +
+

Occupants

+ {{if not .Profile}} +

Create a profile first.

+ {{else}} + {{if .Occupants}} +
+ + + + + + + + + + + + {{range .Occupants}} + + + + + + + + {{end}} + +
RoomCountActivityVulnerable
{{.RoomID}}{{.Count}}{{.ActivityLevel}}{{if .Vulnerable}}Yes{{else}}No{{end}} +
+ +
+
+
+ {{else}} +

No occupants yet.

+ {{end}} + + {{if .Rooms}} +
+

Add Occupant

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ {{else}} +

Add a room first to create occupants.

+ {{end}} + {{end}} +
+{{end}} + +{{define "ac_units"}} +
+

AC Units

+ {{if not .Profile}} +

Create a profile first.

+ {{else}} + {{if .ACUnits}} +
+ {{range .ACUnits}} + {{$acID := .ID}} +
+
+
+ {{.Name}} + {{.ACType}} — {{printf "%.0f" .CapacityBTU}} BTU/h — EER {{printf "%.1f" .EfficiencyEER}} + {{if .HasDehumidify}}Dehumidify{{end}} +
+
+ +
+
+ {{if $.Rooms}} +
+ Assign to: + + +
+ {{if .AssignedRoomIDs}} +
+ {{range .AssignedRoomIDs}} +
+ + +
+ {{end}} +
+ {{end}} + {{end}} +
+ {{end}} +
+ {{else}} +

No AC units yet.

+ {{end}} + +
+

Add AC Unit

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ {{end}} +
+{{end}} + +{{define "toggles"}} +
+

Scenario Toggles

+ {{if not .Profile}} +

Create a profile first.

+ {{else}} +
+
+ {{range $name, $active := .Toggles}} +
+ + + {{$name}} + +
+ {{end}} +
+ + + +
+
+
+ {{end}} +
+{{end}} + +{{define "forecast"}} +
+

Forecast

+ {{if not .Profile}} +

Create a profile first.

+ {{else}} +
+

+ Last fetched: {{if .LastFetch}}{{.LastFetch}}{{else}}never{{end}} +

+
+ +
+
+ {{end}} +
+{{end}} diff --git a/internal/cli/toggle.go b/internal/cli/toggle.go new file mode 100644 index 0000000..cd9e811 --- /dev/null +++ b/internal/cli/toggle.go @@ -0,0 +1,34 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func init() { + toggleCmd := &cobra.Command{ + Use: "toggle ", + 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) +} diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 0000000..ebad1f1 --- /dev/null +++ b/internal/cli/version.go @@ -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) +} diff --git a/internal/cli/web.go b/internal/cli/web.go new file mode 100644 index 0000000..570e306 --- /dev/null +++ b/internal/cli/web.go @@ -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) +} diff --git a/internal/cli/web_handlers.go b/internal/cli/web_handlers.go new file mode 100644 index 0000000..5344e77 --- /dev/null +++ b/internal/cli/web_handlers.go @@ -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, ` + + +
+

Dashboard

+
+

Cannot load dashboard

+

%s

+

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

+
+
`, static.TailwindCSS, err.Error()) + return + } + data.ShowNav = true + html, err := report.GenerateString(data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(html)) + } +} + +// --- CRUD Handlers --- + +func profileAddHandler(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + name := r.FormValue("name") + lat := parseFloatOr(r.FormValue("latitude"), 0) + lon := parseFloatOr(r.FormValue("longitude"), 0) + tz := r.FormValue("timezone") + if tz == "" { + tz = "Europe/Berlin" + } + _, err := db.CreateProfile(name, lat, lon, tz) + if err != nil { + setFlash(w, "Error creating profile: "+err.Error()) + } else { + setFlash(w, "Profile "+name+" created.") + } + http.Redirect(w, r, "/setup#profiles", http.StatusSeeOther) +} + +func profileDeleteHandler(w http.ResponseWriter, r *http.Request) { + id := parseIntOr(r.PathValue("id"), 0) + if id == 0 { + setFlash(w, "Invalid profile ID.") + http.Redirect(w, r, "/setup#profiles", http.StatusSeeOther) + return + } + if err := db.DeleteProfile(id); err != nil { + setFlash(w, "Error deleting profile: "+err.Error()) + } else { + setFlash(w, "Profile deleted.") + } + http.Redirect(w, r, "/setup#profiles", http.StatusSeeOther) +} + +func roomAddHandler(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + p, err := getActiveProfile() + if err != nil { + setFlash(w, "No active profile.") + http.Redirect(w, r, "/setup#rooms", http.StatusSeeOther) + return + } + name := r.FormValue("name") + areaSqm := parseFloatOr(r.FormValue("area_sqm"), 20) + floor := int(parseIntOr(r.FormValue("floor"), 0)) + orientation := r.FormValue("orientation") + shadingType := r.FormValue("shading_type") + shadingFactor := parseFloatOr(r.FormValue("shading_factor"), 1.0) + ventilation := r.FormValue("ventilation") + insulation := r.FormValue("insulation") + params := store.RoomParams{ + CeilingHeightM: parseFloatOr(r.FormValue("ceiling_height_m"), 2.50), + VentilationACH: parseFloatOr(r.FormValue("ventilation_ach"), 1.5), + WindowFraction: parseFloatOr(r.FormValue("window_fraction"), 0.30), + SHGC: parseFloatOr(r.FormValue("shgc"), 0.60), + } + _, err = db.CreateRoom(p.ID, name, areaSqm, floor, orientation, shadingType, shadingFactor, ventilation, insulation, params) + if err != nil { + setFlash(w, "Error creating room: "+err.Error()) + } else { + setFlash(w, "Room "+name+" created.") + } + http.Redirect(w, r, "/setup#rooms", http.StatusSeeOther) +} + +func roomDeleteHandler(w http.ResponseWriter, r *http.Request) { + id := parseIntOr(r.PathValue("id"), 0) + if id == 0 { + setFlash(w, "Invalid room ID.") + http.Redirect(w, r, "/setup#rooms", http.StatusSeeOther) + return + } + if err := db.DeleteRoom(id); err != nil { + setFlash(w, "Error deleting room: "+err.Error()) + } else { + setFlash(w, "Room deleted.") + } + http.Redirect(w, r, "/setup#rooms", http.StatusSeeOther) +} + +func deviceAddHandler(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + roomID := parseIntOr(r.FormValue("room_id"), 0) + name := r.FormValue("name") + deviceType := r.FormValue("device_type") + wattsIdle := parseFloatOr(r.FormValue("watts_idle"), 10) + wattsTypical := parseFloatOr(r.FormValue("watts_typical"), 80) + wattsPeak := parseFloatOr(r.FormValue("watts_peak"), 200) + dutyCycle := parseFloatOr(r.FormValue("duty_cycle"), 0.5) + _, err := db.CreateDevice(roomID, name, deviceType, wattsIdle, wattsTypical, wattsPeak, dutyCycle) + if err != nil { + setFlash(w, "Error creating device: "+err.Error()) + } else { + setFlash(w, "Device "+name+" created.") + } + http.Redirect(w, r, "/setup#devices", http.StatusSeeOther) +} + +func deviceDeleteHandler(w http.ResponseWriter, r *http.Request) { + id := parseIntOr(r.PathValue("id"), 0) + if id == 0 { + setFlash(w, "Invalid device ID.") + http.Redirect(w, r, "/setup#devices", http.StatusSeeOther) + return + } + if err := db.DeleteDevice(id); err != nil { + setFlash(w, "Error deleting device: "+err.Error()) + } else { + setFlash(w, "Device deleted.") + } + http.Redirect(w, r, "/setup#devices", http.StatusSeeOther) +} + +func occupantAddHandler(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + roomID := parseIntOr(r.FormValue("room_id"), 0) + count := int(parseIntOr(r.FormValue("count"), 1)) + activityLevel := r.FormValue("activity_level") + vulnerable := r.FormValue("vulnerable") == "true" + _, err := db.CreateOccupant(roomID, count, activityLevel, vulnerable) + if err != nil { + setFlash(w, "Error creating occupant: "+err.Error()) + } else { + setFlash(w, "Occupant added.") + } + http.Redirect(w, r, "/setup#occupants", http.StatusSeeOther) +} + +func occupantDeleteHandler(w http.ResponseWriter, r *http.Request) { + id := parseIntOr(r.PathValue("id"), 0) + if id == 0 { + setFlash(w, "Invalid occupant ID.") + http.Redirect(w, r, "/setup#occupants", http.StatusSeeOther) + return + } + if err := db.DeleteOccupant(id); err != nil { + setFlash(w, "Error deleting occupant: "+err.Error()) + } else { + setFlash(w, "Occupant deleted.") + } + http.Redirect(w, r, "/setup#occupants", http.StatusSeeOther) +} + +func acAddHandler(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + p, err := getActiveProfile() + if err != nil { + setFlash(w, "No active profile.") + http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) + return + } + name := r.FormValue("name") + acType := r.FormValue("ac_type") + capacityBTU := parseFloatOr(r.FormValue("capacity_btu"), 12000) + efficiencyEER := parseFloatOr(r.FormValue("efficiency_eer"), 10.0) + hasDehumidify := r.FormValue("has_dehumidify") == "true" + _, err = db.CreateACUnit(p.ID, name, acType, capacityBTU, hasDehumidify, efficiencyEER) + if err != nil { + setFlash(w, "Error creating AC unit: "+err.Error()) + } else { + setFlash(w, "AC unit "+name+" created.") + } + http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) +} + +func acDeleteHandler(w http.ResponseWriter, r *http.Request) { + id := parseIntOr(r.PathValue("id"), 0) + if id == 0 { + setFlash(w, "Invalid AC unit ID.") + http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) + return + } + if err := db.DeleteACUnit(id); err != nil { + setFlash(w, "Error deleting AC unit: "+err.Error()) + } else { + setFlash(w, "AC unit deleted.") + } + http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) +} + +func acAssignHandler(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + acID := parseIntOr(r.PathValue("id"), 0) + roomID := parseIntOr(r.FormValue("room_id"), 0) + if acID == 0 || roomID == 0 { + setFlash(w, "Invalid AC or room ID.") + http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) + return + } + if err := db.AssignACToRoom(acID, roomID); err != nil { + setFlash(w, "Error assigning AC: "+err.Error()) + } else { + setFlash(w, "AC unit assigned to room.") + } + http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) +} + +func acUnassignHandler(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + acID := parseIntOr(r.PathValue("id"), 0) + roomID := parseIntOr(r.FormValue("room_id"), 0) + if acID == 0 || roomID == 0 { + setFlash(w, "Invalid AC or room ID.") + http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) + return + } + if err := db.UnassignACFromRoom(acID, roomID); err != nil { + setFlash(w, "Error unassigning AC: "+err.Error()) + } else { + setFlash(w, "AC unit unassigned from room.") + } + http.Redirect(w, r, "/setup#ac", http.StatusSeeOther) +} + +func toggleSetHandler(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + p, err := getActiveProfile() + if err != nil { + setFlash(w, "No active profile.") + http.Redirect(w, r, "/setup#toggles", http.StatusSeeOther) + return + } + name := r.FormValue("name") + active := r.FormValue("active") == "true" + if name == "" { + setFlash(w, "Toggle name is required.") + http.Redirect(w, r, "/setup#toggles", http.StatusSeeOther) + return + } + if err := db.SetToggle(p.ID, name, active); err != nil { + setFlash(w, "Error setting toggle: "+err.Error()) + } else { + state := "OFF" + if active { + state = "ON" + } + setFlash(w, fmt.Sprintf("Toggle %q set to %s.", name, state)) + } + http.Redirect(w, r, "/setup#toggles", http.StatusSeeOther) +} + +func forecastFetchHandler(w http.ResponseWriter, r *http.Request) { + p, err := getActiveProfile() + if err != nil { + setFlash(w, "No active profile.") + http.Redirect(w, r, "/setup#forecast", http.StatusSeeOther) + return + } + if err := fetchForecastForProfile(p); err != nil { + setFlash(w, "Forecast fetch failed: "+err.Error()) + } else { + setFlash(w, "Forecast fetched successfully.") + } + http.Redirect(w, r, "/setup#forecast", http.StatusSeeOther) +} + +// --- 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) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..86a1458 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/heat/budget.go b/internal/heat/budget.go new file mode 100644 index 0000000..0053493 --- /dev/null +++ b/internal/heat/budget.go @@ -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 0–20% 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, + } +} diff --git a/internal/heat/budget_test.go b/internal/heat/budget_test.go new file mode 100644 index 0000000..85cb4a2 --- /dev/null +++ b/internal/heat/budget_test.go @@ -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) + } + }) + } +} diff --git a/internal/heat/external_gains.go b/internal/heat/external_gains.go new file mode 100644 index 0000000..a3248fc --- /dev/null +++ b/internal/heat/external_gains.go @@ -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.0–1.0, varies by hour and wall orientation + CloudFactor float64 // 0.0 (overcast) to 1.0 (clear) + SunshineFraction float64 // fraction of hour with sunshine (0.0–1.0) + PeakIrradiance float64 // W/m² on the surface (e.g., 800 for direct sun) +} + +// OrientationFactor returns a solar exposure factor (0.0–1.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 +} diff --git a/internal/heat/external_gains_test.go b/internal/heat/external_gains_test.go new file mode 100644 index 0000000..2b45d26 --- /dev/null +++ b/internal/heat/external_gains_test.go @@ -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) + } + }) + } +} diff --git a/internal/heat/internal_gains.go b/internal/heat/internal_gains.go new file mode 100644 index 0000000..96df4e6 --- /dev/null +++ b/internal/heat/internal_gains.go @@ -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.0–1.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 +} diff --git a/internal/heat/internal_gains_test.go b/internal/heat/internal_gains_test.go new file mode 100644 index 0000000..4b77680 --- /dev/null +++ b/internal/heat/internal_gains_test.go @@ -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) + } +} diff --git a/internal/heat/units.go b/internal/heat/units.go new file mode 100644 index 0000000..7817b6c --- /dev/null +++ b/internal/heat/units.go @@ -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 +} diff --git a/internal/heat/units_test.go b/internal/heat/units_test.go new file mode 100644 index 0000000..98a020c --- /dev/null +++ b/internal/heat/units_test.go @@ -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) + } +} diff --git a/internal/llm/anthropic.go b/internal/llm/anthropic.go new file mode 100644 index 0000000..bf385fd --- /dev/null +++ b/internal/llm/anthropic.go @@ -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) +} diff --git a/internal/llm/llm_test.go b/internal/llm/llm_test.go new file mode 100644 index 0000000..c6ad98a --- /dev/null +++ b/internal/llm/llm_test.go @@ -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 " +} diff --git a/internal/llm/noop.go b/internal/llm/noop.go new file mode 100644 index 0000000..ae1111a --- /dev/null +++ b/internal/llm/noop.go @@ -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 } diff --git a/internal/llm/ollama.go b/internal/llm/ollama.go new file mode 100644 index 0000000..4bc13e9 --- /dev/null +++ b/internal/llm/ollama.go @@ -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)) +} diff --git a/internal/llm/openai.go b/internal/llm/openai.go new file mode 100644 index 0000000..cb10698 --- /dev/null +++ b/internal/llm/openai.go @@ -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)) +} diff --git a/internal/llm/prompt.go b/internal/llm/prompt.go new file mode 100644 index 0000000..ca1d767 --- /dev/null +++ b/internal/llm/prompt.go @@ -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 } diff --git a/internal/llm/prompt_test.go b/internal/llm/prompt_test.go new file mode 100644 index 0000000..e1e945c --- /dev/null +++ b/internal/llm/prompt_test.go @@ -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") + } +} diff --git a/internal/llm/provider.go b/internal/llm/provider.go new file mode 100644 index 0000000..22aaf9b --- /dev/null +++ b/internal/llm/provider.go @@ -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 +} diff --git a/internal/report/data.go b/internal/report/data.go new file mode 100644 index 0000000..fcbe5ce --- /dev/null +++ b/internal/report/data.go @@ -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 +} diff --git a/internal/report/generator.go b/internal/report/generator.go new file mode 100644 index 0000000..0a5d5a9 --- /dev/null +++ b/internal/report/generator.go @@ -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" + } +} diff --git a/internal/report/generator_test.go b/internal/report/generator_test.go new file mode 100644 index 0000000..7992a66 --- /dev/null +++ b/internal/report/generator_test.go @@ -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, "") { + t.Error("missing DOCTYPE") + } + if !strings.Contains(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()) + } +} diff --git a/internal/report/sample_test.go b/internal/report/sample_test.go new file mode 100644 index 0000000..94f82f1 --- /dev/null +++ b/internal/report/sample_test.go @@ -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") +} diff --git a/internal/report/templates/dashboard.html.tmpl b/internal/report/templates/dashboard.html.tmpl new file mode 100644 index 0000000..1e1af26 --- /dev/null +++ b/internal/report/templates/dashboard.html.tmpl @@ -0,0 +1,166 @@ + + + + + +Heatwave Report — {{.ProfileName}} — {{.Date}} + + + +{{if .ShowNav}} + +{{end}} +
+ +
+

Heatwave Report

+

{{.ProfileName}} — {{.Date}}

+

Generated {{.GeneratedAt.Format "2006-01-02 15:04"}}

+
+ +{{template "warnings" .}} +{{template "risk_summary" .}} +{{template "timeline" .}} +{{template "heatbudget" .}} +{{template "checklist" .}} + +{{if .LLMSummary}} +
+

AI Summary

+ {{if .LLMDisclaimer}}

{{.LLMDisclaimer}}

{{end}} +
{{.LLMSummary}}
+
+{{end}} + +
+

Heatwave Autopilot — This report is for planning purposes only. It does not constitute medical advice.

+
+ +
+ + + +{{define "warnings"}} +{{if .Warnings}} +
+{{range .Warnings}} +
+

{{.Headline}}

+

{{.Description}}

+ {{if .Instruction}}

{{.Instruction}}

{{end}} +

{{.Onset}} — {{.Expires}}

+
+{{end}} +
+{{end}} +{{end}} + +{{define "risk_summary"}} +
+
+

Risk Level

+

{{riskBadge .RiskLevel}}

+
+
+

Peak Temperature

+

{{formatTemp .PeakTempC}}

+
+
+

Min Night Temp

+

{{formatTemp .MinNightTempC}}

+ {{if .PoorNightCool}}

Poor night cooling

{{end}} +
+
+ +{{if .RiskWindows}} +
+

Risk Windows

+
+ {{range .RiskWindows}} +
+ {{printf "%02d:00" .StartHour}} — {{printf "%02d:00" .EndHour}} + Peak {{formatTemp .PeakTempC}} + {{.Level}} +
+ {{end}} +
+
+{{end}} +{{end}} + +{{define "timeline"}} +
+

Hour-by-Hour Timeline

+
+ + + + + + + + + + + + {{range .Timeline}} + + + + + + + + {{end}} + +
HourTempRiskBudgetActions
{{.HourStr}}{{formatTemp .TempC}}{{riskBadge .RiskLevel}}{{statusBadge .BudgetStatus}} + {{range .Actions}} + {{.Name}} + {{end}} +
+
+
+{{end}} + +{{define "heatbudget"}} +{{if .RoomBudgets}} +
+

Room Heat Budgets

+
+ {{range .RoomBudgets}} +
+

{{.RoomName}}

+ + + + + + + +
Internal gains{{formatWatts .InternalGainsW}}
Solar gain{{formatWatts .SolarGainW}}
Ventilation{{formatWatts .VentGainW}}
Total heat load{{formatWatts .TotalGainW}} ({{formatBTU .TotalGainBTUH}})
AC capacity{{formatBTU .ACCapacityBTUH}}
Headroom{{formatBTU .HeadroomBTUH}}
+

{{.Status}}

+
+ {{end}} +
+
+{{end}} +{{end}} + +{{define "checklist"}} +{{if .CareChecklist}} +
+

Care Checklist

+
    + {{range .CareChecklist}} +
  • {{.}}
  • + {{end}} +
+
+{{end}} +{{end}} diff --git a/internal/risk/analyzer.go b/internal/risk/analyzer.go new file mode 100644 index 0000000..8e91c3c --- /dev/null +++ b/internal/risk/analyzer.go @@ -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 "" + } +} diff --git a/internal/risk/analyzer_test.go b/internal/risk/analyzer_test.go new file mode 100644 index 0000000..127409c --- /dev/null +++ b/internal/risk/analyzer_test.go @@ -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) + } + } +} diff --git a/internal/risk/thresholds.go b/internal/risk/thresholds.go new file mode 100644 index 0000000..104d82d --- /dev/null +++ b/internal/risk/thresholds.go @@ -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, + } +} diff --git a/internal/static/embed.go b/internal/static/embed.go new file mode 100644 index 0000000..bc15d48 --- /dev/null +++ b/internal/static/embed.go @@ -0,0 +1,6 @@ +package static + +import _ "embed" + +//go:embed tailwind.css +var TailwindCSS string diff --git a/internal/static/tailwind.css b/internal/static/tailwind.css new file mode 100644 index 0000000..fe8e2d0 --- /dev/null +++ b/internal/static/tailwind.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-400:oklch(75% .183 55.934);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-950:oklch(26.6% .079 36.259);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-950:oklch(28.6% .066 53.813);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-400:oklch(79.2% .209 151.711);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-white:#fff;--spacing:.25rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--radius-lg:.5rem;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse{visibility:collapse}.relative{position:relative}.static{position:static}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-6{margin-top:calc(var(--spacing)*6)}.mt-8{margin-top:calc(var(--spacing)*8)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.ml-1{margin-left:calc(var(--spacing)*1)}.ml-2{margin-left:calc(var(--spacing)*2)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.w-full{width:100%}.max-w-4xl{max-width:var(--container-4xl)}.min-w-0{min-width:calc(var(--spacing)*0)}.flex-1{flex:1}.grow{flex-grow:1}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.resize{resize:both}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.gap-1{gap:calc(var(--spacing)*1)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.border{border-style:var(--tw-border-style);border-width:1px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-green-400{border-color:var(--color-green-400)}.border-orange-400{border-color:var(--color-orange-400)}.border-red-400{border-color:var(--color-red-400)}.border-yellow-400{border-color:var(--color-yellow-400)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-400{background-color:var(--color-gray-400)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-600{background-color:var(--color-green-600)}.bg-orange-50{background-color:var(--color-orange-50)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-orange-600{background-color:var(--color-orange-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-600{background-color:var(--color-red-600)}.bg-white{background-color:var(--color-white)}.bg-yellow-50{background-color:var(--color-yellow-50)}.bg-yellow-100{background-color:var(--color-yellow-100)}.bg-yellow-600{background-color:var(--color-yellow-600)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.py-0\.5{padding-block:calc(var(--spacing)*.5)}.py-1{padding-block:calc(var(--spacing)*1)}.py-2{padding-block:calc(var(--spacing)*2)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.pb-2{padding-bottom:calc(var(--spacing)*2)}.text-center{text-align:center}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-600{color:var(--color-green-600)}.text-green-800{color:var(--color-green-800)}.text-orange-600{color:var(--color-orange-600)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-white{color:var(--color-white)}.text-yellow-600{color:var(--color-yellow-600)}.underline{text-decoration-line:underline}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}@media (hover:hover){.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}.hover\:bg-gray-300:hover{background-color:var(--color-gray-300)}.hover\:bg-green-600:hover{background-color:var(--color-green-600)}.hover\:bg-green-700:hover{background-color:var(--color-green-700)}.hover\:bg-red-600:hover{background-color:var(--color-red-600)}.hover\:text-blue-600:hover{color:var(--color-blue-600)}.hover\:underline:hover{text-decoration-line:underline}}@media (min-width:40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (prefers-color-scheme:dark){.dark\:border-gray-600{border-color:var(--color-gray-600)}.dark\:bg-blue-900{background-color:var(--color-blue-900)}.dark\:bg-blue-950{background-color:var(--color-blue-950)}.dark\:bg-gray-700{background-color:var(--color-gray-700)}.dark\:bg-gray-800{background-color:var(--color-gray-800)}.dark\:bg-gray-900{background-color:var(--color-gray-900)}.dark\:bg-green-900{background-color:var(--color-green-900)}.dark\:bg-orange-950{background-color:var(--color-orange-950)}.dark\:bg-red-950{background-color:var(--color-red-950)}.dark\:bg-yellow-950{background-color:var(--color-yellow-950)}.dark\:text-blue-200{color:var(--color-blue-200)}.dark\:text-blue-300{color:var(--color-blue-300)}.dark\:text-blue-400{color:var(--color-blue-400)}.dark\:text-gray-100{color:var(--color-gray-100)}.dark\:text-gray-300{color:var(--color-gray-300)}.dark\:text-gray-400{color:var(--color-gray-400)}.dark\:text-gray-500{color:var(--color-gray-500)}.dark\:text-green-200{color:var(--color-green-200)}.dark\:text-green-400{color:var(--color-green-400)}.dark\:text-orange-400{color:var(--color-orange-400)}.dark\:text-red-400{color:var(--color-red-400)}.dark\:text-yellow-400{color:var(--color-yellow-400)}.dark\:shadow-gray-700{--tw-shadow-color:oklch(37.3% .034 259.733)}@supports (color:color-mix(in lab, red, red)){.dark\:shadow-gray-700{--tw-shadow-color:color-mix(in oklab,var(--color-gray-700)var(--tw-shadow-alpha),transparent)}}@media (hover:hover){.dark\:hover\:bg-gray-600:hover{background-color:var(--color-gray-600)}.dark\:hover\:text-blue-400:hover{color:var(--color-blue-400)}}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} \ No newline at end of file diff --git a/internal/store/ac.go b/internal/store/ac.go new file mode 100644 index 0000000..e6238fe --- /dev/null +++ b/internal/store/ac.go @@ -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() +} diff --git a/internal/store/device.go b/internal/store/device.go new file mode 100644 index 0000000..96a5d38 --- /dev/null +++ b/internal/store/device.go @@ -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 +} diff --git a/internal/store/forecast.go b/internal/store/forecast.go new file mode 100644 index 0000000..d8b4cca --- /dev/null +++ b/internal/store/forecast.go @@ -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() +} diff --git a/internal/store/migrations.go b/internal/store/migrations.go new file mode 100644 index 0000000..35430d1 --- /dev/null +++ b/internal/store/migrations.go @@ -0,0 +1,6 @@ +package store + +import _ "embed" + +//go:embed schema.sql +var schemaSQL string diff --git a/internal/store/occupant.go b/internal/store/occupant.go new file mode 100644 index 0000000..4de92e7 --- /dev/null +++ b/internal/store/occupant.go @@ -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 +} diff --git a/internal/store/profile.go b/internal/store/profile.go new file mode 100644 index 0000000..31d6edb --- /dev/null +++ b/internal/store/profile.go @@ -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() +} diff --git a/internal/store/room.go b/internal/store/room.go new file mode 100644 index 0000000..09833b5 --- /dev/null +++ b/internal/store/room.go @@ -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 +} diff --git a/internal/store/schema.sql b/internal/store/schema.sql new file mode 100644 index 0000000..4af3f0b --- /dev/null +++ b/internal/store/schema.sql @@ -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 +); diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..fbf6f78 --- /dev/null +++ b/internal/store/store.go @@ -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 +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..6eafff9 --- /dev/null +++ b/internal/store/store_test.go @@ -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") + } +} diff --git a/internal/store/toggle.go b/internal/store/toggle.go new file mode 100644 index 0000000..891b9d6 --- /dev/null +++ b/internal/store/toggle.go @@ -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 +} diff --git a/internal/store/warning.go b/internal/store/warning.go new file mode 100644 index 0000000..988293b --- /dev/null +++ b/internal/store/warning.go @@ -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() +} diff --git a/internal/weather/brightsky.go b/internal/weather/brightsky.go new file mode 100644 index 0000000..db5f022 --- /dev/null +++ b/internal/weather/brightsky.go @@ -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 +} diff --git a/internal/weather/brightsky_test.go b/internal/weather/brightsky_test.go new file mode 100644 index 0000000..bac0513 --- /dev/null +++ b/internal/weather/brightsky_test.go @@ -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") + } +} diff --git a/internal/weather/dwd_wfs.go b/internal/weather/dwd_wfs.go new file mode 100644 index 0000000..037ac37 --- /dev/null +++ b/internal/weather/dwd_wfs.go @@ -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 +} diff --git a/internal/weather/dwd_wfs_test.go b/internal/weather/dwd_wfs_test.go new file mode 100644 index 0000000..3ba1c25 --- /dev/null +++ b/internal/weather/dwd_wfs_test.go @@ -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)) + } +} diff --git a/internal/weather/openmeteo.go b/internal/weather/openmeteo.go new file mode 100644 index 0000000..1b1d1bb --- /dev/null +++ b/internal/weather/openmeteo.go @@ -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 +} diff --git a/internal/weather/openmeteo_test.go b/internal/weather/openmeteo_test.go new file mode 100644 index 0000000..3bd0ea8 --- /dev/null +++ b/internal/weather/openmeteo_test.go @@ -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") + } +} diff --git a/internal/weather/provider.go b/internal/weather/provider.go new file mode 100644 index 0000000..ee06e64 --- /dev/null +++ b/internal/weather/provider.go @@ -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 +} diff --git a/internal/weather/types.go b/internal/weather/types.go new file mode 100644 index 0000000..55704e8 --- /dev/null +++ b/internal/weather/types.go @@ -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 +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..38c304d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1055 @@ +{ + "name": "heatwave-autopilot", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "heatwave-autopilot", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@tailwindcss/cli": "^4.1.18", + "tailwindcss": "^4.1.18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/cli": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.18.tgz", + "integrity": "sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/watcher": "^2.5.1", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "enhanced-resolve": "^5.18.3", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.1.18" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6716638 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/tailwind/input.css b/tailwind/input.css new file mode 100644 index 0000000..91bf218 --- /dev/null +++ b/tailwind/input.css @@ -0,0 +1,3 @@ +@import "tailwindcss"; +@source "../internal/report/templates/*.tmpl"; +@source "../internal/cli/templates/*.tmpl";