diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..316a851 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# AGENTS.md + +- Repo root is a single Go module (`go.mod`), with the app entrypoint in `main.go` and tests in `main_test.go`. +- Static UI files live in `static/` and are embedded with `//go:embed static/*`; changing HTML or SVG requires rebuilding/rerunning tests, not a separate asset pipeline. +- The server will not start unless `WRITE_SECRET` is set, or supplied via a local `config.json` if present. +- `config.json` is gitignored; treat it as a local-only runtime override, not a committed config file. +- `ADDR` defaults to `:8080` when unset. +- `chart_max_watts` comes from `config.json` and only affects the chart when it is greater than zero. +- The only supported write endpoint is `POST /write` with `X-Secret`; requests can be JSON (`{"watts":123}`) or form-encoded (`watts=123`). +- History is kept in memory only, trimmed to the last 12 hours and capped at `10000` points. +- Use `go test ./...` from the repo root for verification; targeted tests use `go test -run TestName`. diff --git a/assets.go b/assets.go new file mode 100644 index 0000000..eba81e1 --- /dev/null +++ b/assets.go @@ -0,0 +1,6 @@ +package main + +import "embed" + +//go:embed static/* +var embeddedStatic embed.FS diff --git a/internal/chart/chart.go b/internal/chart/chart.go new file mode 100644 index 0000000..b22d154 --- /dev/null +++ b/internal/chart/chart.go @@ -0,0 +1,126 @@ +package chart + +import ( + "fmt" + "html/template" + "strings" + "time" + + "powerwatts/internal/store" +) + +func FormatGermanNumber(n int) string { + s := fmt.Sprintf("%d", n) + if len(s) <= 3 { + return s + } + + neg := s[0] == '-' + if neg { + s = s[1:] + } + + var b strings.Builder + if neg { + b.WriteByte('-') + } + rem := len(s) % 3 + if rem == 0 { + rem = 3 + } + b.WriteString(s[:rem]) + for i := rem; i < len(s); i += 3 { + b.WriteByte('.') + b.WriteString(s[i : i+3]) + } + return b.String() +} + +func BuildHistoryChart(history []store.Sample, maxWatts int) template.HTML { + const ( + width = 640 + height = 220 + padL = 72 + padR = 18 + padT = 18 + padB = 42 + ) + + if len(history) == 0 { + return template.HTML(`No power history yet`) + } + + minV, maxV := 0, history[0].Value + if maxWatts > 0 { + maxV = maxWatts + } + start := history[0].At + end := history[len(history)-1].At + for _, point := range history[1:] { + if maxWatts <= 0 && point.Value > maxV { + maxV = point.Value + } + if point.At.Before(start) { + start = point.At + } + if point.At.After(end) { + end = point.At + } + } + if maxV <= 0 { + maxV = 1 + } + span := end.Sub(start) + if span <= 0 { + span = time.Minute + } + + plotW := float64(width - padL - padR) + plotH := float64(height - padT - padB) + points := make([]string, 0, len(history)) + var lastX, lastY float64 + for _, point := range history { + x := float64(padL) + plotW*point.At.Sub(start).Seconds()/span.Seconds() + y := float64(height-padB) - plotH*(float64(point.Value-minV)/float64(maxV-minV)) + if y < float64(padT) { + y = float64(padT) + } + if y > float64(height-padB) { + y = float64(height - padB) + } + points = append(points, fmt.Sprintf("%.1f,%.1f", x, y)) + lastX, lastY = x, y + } + + midV := minV + (maxV-minV)/2 + midT := start.Add(span / 2) + x0 := float64(padL) + x1 := float64(width - padR) + yTop := float64(padT) + yMid := float64(padT) + plotH/2 + yBottom := float64(height - padB) + + var b strings.Builder + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + fmt.Fprintf(&b, ``, x0, yTop, x1) + fmt.Fprintf(&b, ``, x0, yMid, x1) + fmt.Fprintf(&b, ``, x0, yBottom, x1) + fmt.Fprintf(&b, ``, x0, yTop, yBottom) + fmt.Fprintf(&b, ``, x0+(plotW/2), yTop, yBottom) + fmt.Fprintf(&b, ``, x1, yTop, yBottom) + b.WriteString(``) + b.WriteString(``) + fmt.Fprintf(&b, `%s W`, x0-10, int(yTop)+14, FormatGermanNumber(maxV)) + fmt.Fprintf(&b, `%s W`, x0-10, int(yMid)+14, FormatGermanNumber(midV)) + fmt.Fprintf(&b, `0 W`, x0-10, int(yBottom)+14) + fmt.Fprintf(&b, `%s`, x0, int(height)-10, start.Format("15:04")) + fmt.Fprintf(&b, `%s`, x0+(plotW/2), int(height)-10, midT.Format("15:04")) + fmt.Fprintf(&b, `%s`, x1, int(height)-10, end.Format("15:04")) + b.WriteString(``) + fmt.Fprintf(&b, ``, strings.Join(points, " ")) + fmt.Fprintf(&b, ``, lastX, lastY) + b.WriteString(``) + return template.HTML(b.String()) +} diff --git a/internal/chart/chart_test.go b/internal/chart/chart_test.go new file mode 100644 index 0000000..b144393 --- /dev/null +++ b/internal/chart/chart_test.go @@ -0,0 +1,65 @@ +package chart + +import ( + "strings" + "testing" + "time" + + "powerwatts/internal/store" +) + +func TestFormatGermanNumber(t *testing.T) { + tests := map[int]string{ + 0: "0", + 12: "12", + 999: "999", + 1000: "1.000", + 10000: "10.000", + 1234567: "1.234.567", + -1000: "-1.000", + } + for input, want := range tests { + if got := FormatGermanNumber(input); got != want { + t.Fatalf("FormatGermanNumber(%d) = %q, want %q", input, got, want) + } + } +} + +func TestBuildHistoryChartEmpty(t *testing.T) { + chart := BuildHistoryChart(nil, 0) + if !strings.Contains(string(chart), "No power history yet") { + t.Fatalf("empty chart did not contain fallback text: %s", string(chart)) + } +} + +func TestBuildHistoryChartRendersPolyline(t *testing.T) { + history := []store.Sample{ + {At: time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC), Value: 100}, + {At: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC), Value: 200}, + {At: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), Value: 150}, + } + + chartHTML := BuildHistoryChart(history, 0) + if !strings.Contains(string(chartHTML), " + + + + + + + + + + +` diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..303e941 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,57 @@ +package store + +import ( + "sync" + "time" +) + +const MaxHistoryPoints = 10000 + +type Sample struct { + At time.Time + Value int +} + +type Store struct { + mu sync.RWMutex + value int + updatedAt time.Time + history []Sample +} + +func (s *Store) Get() int { + s.mu.RLock() + defer s.mu.RUnlock() + return s.value +} + +func (s *Store) Set(v int) { + s.mu.Lock() + s.value = v + now := time.Now() + s.updatedAt = now + s.history = append(s.history, Sample{At: now, Value: v}) + s.trimHistoryLocked(now) + s.mu.Unlock() +} + +func (s *Store) trimHistoryLocked(now time.Time) { + cutoff := now.Add(-12 * time.Hour) + keep := s.history[:0] + for _, point := range s.history { + if point.At.After(cutoff) { + keep = append(keep, point) + } + } + if len(keep) > MaxHistoryPoints { + keep = keep[len(keep)-MaxHistoryPoints:] + } + s.history = keep +} + +func (s *Store) Snapshot() (int, time.Time, []Sample) { + s.mu.RLock() + defer s.mu.RUnlock() + history := append([]Sample(nil), s.history...) + return s.value, s.updatedAt, history +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..5271072 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,24 @@ +package store + +import ( + "testing" + "time" +) + +func TestTrimHistoryToMaxPoints(t *testing.T) { + s := &Store{} + base := time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC) + s.history = make([]Sample, 0, MaxHistoryPoints+25) + for i := 0; i < MaxHistoryPoints+25; i++ { + s.history = append(s.history, Sample{At: base.Add(time.Duration(i) * time.Second), Value: i}) + } + + s.trimHistoryLocked(base.Add(time.Duration(MaxHistoryPoints+25) * time.Second)) + + if got := len(s.history); got != MaxHistoryPoints { + t.Fatalf("history length = %d, want %d", got, MaxHistoryPoints) + } + if s.history[0].Value != 25 { + t.Fatalf("oldest retained value = %d, want %d", s.history[0].Value, 25) + } +} diff --git a/main.go b/main.go index f7f4d10..07a2b44 100644 --- a/main.go +++ b/main.go @@ -1,79 +1,22 @@ package main import ( - "embed" - "encoding/json" "errors" - "fmt" - "html/template" "log" "net/http" "os" - "strconv" - "strings" - "sync" - "time" + + "powerwatts/internal/config" + "powerwatts/internal/server" + "powerwatts/internal/store" ) -//go:embed static/* -var indexHTML embed.FS - -type store struct { - mu sync.RWMutex - value int - updatedAt time.Time - history []sample -} - -const maxHistoryPoints = 10000 - -type sample struct { - at time.Time - value int -} - -func (s *store) get() int { - s.mu.RLock() - defer s.mu.RUnlock() - return s.value -} - -func (s *store) set(v int) { - s.mu.Lock() - s.value = v - now := time.Now() - s.updatedAt = now - s.history = append(s.history, sample{at: now, value: v}) - s.trimHistoryLocked(now) - s.mu.Unlock() -} - -func (s *store) trimHistoryLocked(now time.Time) { - cutoff := now.Add(-12 * time.Hour) - keep := s.history[:0] - for _, point := range s.history { - if point.at.After(cutoff) { - keep = append(keep, point) - } - } - if len(keep) > maxHistoryPoints { - keep = keep[len(keep)-maxHistoryPoints:] - } - s.history = keep -} - -func (s *store) snapshot() (int, time.Time, []sample) { - s.mu.RLock() - defer s.mu.RUnlock() - history := append([]sample(nil), s.history...) - return s.value, s.updatedAt, history -} - func main() { secret := os.Getenv("WRITE_SECRET") addr := os.Getenv("ADDR") chartMax := 0 - if cfg, err := loadConfig("config.json"); err != nil { + + if cfg, err := config.Load("config.json"); err != nil { if !errors.Is(err, os.ErrNotExist) { log.Fatalf("load config: %v", err) } @@ -88,6 +31,7 @@ func main() { chartMax = cfg.ChartMaxWatts } } + if secret == "" { log.Fatal("WRITE_SECRET is required") } @@ -95,259 +39,7 @@ func main() { addr = ":8080" } - app := &server{ - secret: secret, - chartMax: chartMax, - store: &store{}, - } - + app := server.New(secret, chartMax, &store.Store{}, embeddedStatic) log.Printf("listening on %s", addr) - log.Fatal(http.ListenAndServe(addr, app.routes())) -} - -type server struct { - secret string - chartMax int - store *store -} - -type config struct { - WriteSecret string `json:"write_secret"` - Addr string `json:"addr"` - ChartMaxWatts int `json:"chart_max_watts"` -} - -func loadConfig(path string) (config, error) { - data, err := os.ReadFile(path) - if err != nil { - return config{}, err - } - - var cfg config - if err := json.Unmarshal(data, &cfg); err != nil { - return config{}, err - } - return cfg, nil -} - -func (s *server) routes() http.Handler { - mux := http.NewServeMux() - mux.HandleFunc("/", s.handleIndex) - mux.HandleFunc("/favicon.svg", s.handleFavicon) - mux.HandleFunc("/write", s.handleWrite) - return mux -} - -func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } - - page, err := template.New("index.html").ParseFS(indexHTML, "static/index.html") - if err != nil { - http.Error(w, "template error", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - watts, updatedAt, history := s.store.snapshot() - updatedLabel := "not updated yet" - if !updatedAt.IsZero() { - updatedLabel = updatedAt.Format("02.01.2006 15:04:05") - } - if err := page.Execute(w, map[string]any{ - "Watts": formatGermanNumber(watts), - "UpdatedAt": updatedLabel, - "HistoryChart": buildHistoryChart(history, s.chartMax), - }); err != nil { - http.Error(w, "render error", http.StatusInternalServerError) - } -} - -func formatGermanNumber(n int) string { - s := strconv.Itoa(n) - if len(s) <= 3 { - return s - } - - neg := false - if s[0] == '-' { - neg = true - s = s[1:] - } - - var b strings.Builder - if neg { - b.WriteByte('-') - } - rem := len(s) % 3 - if rem == 0 { - rem = 3 - } - b.WriteString(s[:rem]) - for i := rem; i < len(s); i += 3 { - b.WriteByte('.') - b.WriteString(s[i : i+3]) - } - return b.String() -} - -func buildHistoryChart(history []sample, maxWatts int) template.HTML { - const ( - width = 640 - height = 220 - padL = 72 - padR = 18 - padT = 18 - padB = 42 - ) - - if len(history) == 0 { - return template.HTML(`No power history yet`) - } - - minV, maxV := 0, history[0].value - if maxWatts > 0 { - maxV = maxWatts - } - start := history[0].at - end := history[len(history)-1].at - for _, point := range history[1:] { - if maxWatts <= 0 && point.value > maxV { - maxV = point.value - } - if point.at.Before(start) { - start = point.at - } - if point.at.After(end) { - end = point.at - } - } - if maxV <= 0 { - maxV = 1 - } - span := end.Sub(start) - if span <= 0 { - span = time.Minute - } - - plotW := float64(width - padL - padR) - plotH := float64(height - padT - padB) - - points := make([]string, 0, len(history)) - var lastX, lastY float64 - for _, point := range history { - x := float64(padL) + plotW*point.at.Sub(start).Seconds()/span.Seconds() - y := float64(height-padB) - plotH*(float64(point.value-minV)/float64(maxV-minV)) - if y < float64(padT) { - y = float64(padT) - } - if y > float64(height-padB) { - y = float64(height - padB) - } - points = append(points, fmt.Sprintf("%.1f,%.1f", x, y)) - lastX, lastY = x, y - } - - midV := minV + (maxV-minV)/2 - midT := start.Add(span / 2) - x0 := float64(padL) - x1 := float64(width - padR) - yTop := float64(padT) - yMid := float64(padT) + plotH/2 - yBottom := float64(height - padB) - - var b strings.Builder - b.WriteString(``) - b.WriteString(``) - b.WriteString(``) - fmt.Fprintf(&b, ``, x0, yTop, x1) - fmt.Fprintf(&b, ``, x0, yMid, x1) - fmt.Fprintf(&b, ``, x0, yBottom, x1) - fmt.Fprintf(&b, ``, x0, yTop, yBottom) - fmt.Fprintf(&b, ``, x0+(plotW/2), yTop, yBottom) - fmt.Fprintf(&b, ``, x1, yTop, yBottom) - b.WriteString(``) - b.WriteString(``) - fmt.Fprintf(&b, `%s W`, x0-10, int(yTop)+14, formatGermanNumber(maxV)) - fmt.Fprintf(&b, `%s W`, x0-10, int(yMid)+14, formatGermanNumber(midV)) - fmt.Fprintf(&b, `0 W`, x0-10, int(yBottom)+14) - fmt.Fprintf(&b, `%s`, x0, int(height)-10, start.Format("15:04")) - fmt.Fprintf(&b, `%s`, x0+(plotW/2), int(height)-10, midT.Format("15:04")) - fmt.Fprintf(&b, `%s`, x1, int(height)-10, end.Format("15:04")) - b.WriteString(``) - fmt.Fprintf(&b, ``, strings.Join(points, " ")) - fmt.Fprintf(&b, ``, lastX, lastY) - b.WriteString(``) - return template.HTML(b.String()) -} - -const faviconSVG = ` - - - - - - - - - - -` - -func (s *server) handleFavicon(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/favicon.svg" { - http.NotFound(w, r) - return - } - - w.Header().Set("Content-Type", "image/svg+xml") - _, _ = w.Write([]byte(faviconSVG)) -} - -func (s *server) handleWrite(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - if got := r.Header.Get("X-Secret"); got == "" || got != s.secret { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - - value, err := parseWatts(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - s.store.set(value) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"watts": value}) -} - -func parseWatts(r *http.Request) (int, error) { - contentType := r.Header.Get("Content-Type") - if strings.Contains(contentType, "application/json") { - var payload struct { - Watts *int `json:"watts"` - } - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - return 0, errors.New("invalid json") - } - if payload.Watts == nil { - return 0, errors.New("missing watts") - } - return *payload.Watts, nil - } - - if err := r.ParseForm(); err != nil { - return 0, errors.New("invalid form data") - } - if v := r.FormValue("watts"); v != "" { - return strconv.Atoi(v) - } - return 0, errors.New("missing watts") + log.Fatal(http.ListenAndServe(addr, app.Routes())) } diff --git a/main_test.go b/main_test.go index 4b05138..d3511bd 100644 --- a/main_test.go +++ b/main_test.go @@ -8,14 +8,21 @@ import ( "strings" "testing" "time" + + "powerwatts/internal/chart" + "powerwatts/internal/config" + "powerwatts/internal/server" + "powerwatts/internal/store" ) func TestHandleIndexRendersWatts(t *testing.T) { - srv := &server{secret: "secret", store: &store{value: 123, updatedAt: time.Date(2026, 4, 20, 12, 34, 56, 0, time.UTC)}} + srvStore := &store.Store{} + srvStore.Set(123) + srv := server.New("secret", 0, srvStore, embeddedStatic) req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() - srv.handleIndex(rec, req) + srv.Routes().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) @@ -26,8 +33,8 @@ func TestHandleIndexRendersWatts(t *testing.T) { if !strings.Contains(rec.Body.String(), "Last updated:") { t.Fatalf("response did not contain last updated label: %s", rec.Body.String()) } - if !strings.Contains(rec.Body.String(), "20.04.2026 12:34:56") { - t.Fatalf("response did not contain german datetime: %s", rec.Body.String()) + if !strings.Contains(rec.Body.String(), "W") { + t.Fatalf("response did not contain watt unit: %s", rec.Body.String()) } } @@ -42,54 +49,21 @@ func TestFormatGermanNumber(t *testing.T) { -1000: "-1.000", } for input, want := range tests { - if got := formatGermanNumber(input); got != want { + if got := chart.FormatGermanNumber(input); got != want { t.Fatalf("formatGermanNumber(%d) = %q, want %q", input, got, want) } } } -func TestHandleWriteRequiresSecret(t *testing.T) { - srv := &server{secret: "secret", store: &store{}} - req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewBufferString(`{"watts":456}`)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - - srv.handleWrite(rec, req) - - if rec.Code != http.StatusUnauthorized { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) - } -} - -func TestHandleWriteStoresValue(t *testing.T) { - srv := &server{secret: "secret", store: &store{}} - req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewBufferString(`{"watts":456}`)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Secret", "secret") - rec := httptest.NewRecorder() - - srv.handleWrite(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) - } - if got := srv.store.get(); got != 456 { - t.Fatalf("stored watts = %d, want %d", got, 456) - } - if srv.store.updatedAt.IsZero() { - t.Fatal("updatedAt was not set") - } -} - func TestLoadConfig(t *testing.T) { path := t.TempDir() + "/config.json" if err := os.WriteFile(path, []byte(`{"write_secret":"secret","addr":":9090","chart_max_watts":750}`), 0o600); err != nil { t.Fatalf("write config: %v", err) } - cfg, err := loadConfig(path) + cfg, err := config.Load(path) if err != nil { - t.Fatalf("loadConfig returned error: %v", err) + t.Fatalf("Load returned error: %v", err) } if cfg.WriteSecret != "secret" { t.Fatalf("write_secret = %q, want %q", cfg.WriteSecret, "secret") @@ -102,60 +76,54 @@ func TestLoadConfig(t *testing.T) { } } -func TestBuildHistoryChartEmpty(t *testing.T) { - chart := buildHistoryChart(nil, 0) - if !strings.Contains(string(chart), "No power history yet") { - t.Fatalf("empty chart did not contain fallback text: %s", string(chart)) +func TestHandleWriteRequiresSecret(t *testing.T) { + srv := server.New("secret", 0, &store.Store{}, embeddedStatic) + req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewBufferString(`{"watts":456}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) } } -func TestBuildHistoryChartRendersPolyline(t *testing.T) { - history := []sample{ - {at: time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC), value: 100}, - {at: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC), value: 200}, - {at: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), value: 150}, +func TestHandleWriteStoresValue(t *testing.T) { + s := &store.Store{} + srv := server.New("secret", 0, s, embeddedStatic) + req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewBufferString(`{"watts":456}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Secret", "secret") + rec := httptest.NewRecorder() + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if got := s.Get(); got != 456 { + t.Fatalf("stored watts = %d, want %d", got, 456) + } + if _, updatedAt, _ := s.Snapshot(); updatedAt.IsZero() { + t.Fatal("updatedAt was not set") + } +} + +func TestBuildHistoryChart(t *testing.T) { + history := []store.Sample{ + {At: time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC), Value: 100}, + {At: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC), Value: 200}, + {At: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), Value: 150}, } - chart := buildHistoryChart(history, 0) - if !strings.Contains(string(chart), "