nicer plot

This commit is contained in:
flo_isea committed 2026-04-20 11:01:59 +02:00
1 parent 624ff1da42
commit 1275e5a821
2 files changed
+66 -8

No files matched your search

+30 -5
View File
@@ -25,6 +25,8 @@ type store struct {
history []sample history []sample
} }
const maxHistoryPoints = 10000
type sample struct { type sample struct {
at time.Time at time.Time
value int value int
@@ -42,6 +44,11 @@ func (s *store) set(v int) {
now := time.Now() now := time.Now()
s.updatedAt = now s.updatedAt = now
s.history = append(s.history, sample{at: now, value: v}) 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) cutoff := now.Add(-12 * time.Hour)
keep := s.history[:0] keep := s.history[:0]
for _, point := range s.history { for _, point := range s.history {
@@ -49,8 +56,10 @@ func (s *store) set(v int) {
keep = append(keep, point) keep = append(keep, point)
} }
} }
if len(keep) > maxHistoryPoints {
keep = keep[len(keep)-maxHistoryPoints:]
}
s.history = keep s.history = keep
s.mu.Unlock()
} }
func (s *store) snapshot() (int, time.Time, []sample) { func (s *store) snapshot() (int, time.Time, []sample) {
@@ -63,6 +72,7 @@ func (s *store) snapshot() (int, time.Time, []sample) {
func main() { func main() {
secret := os.Getenv("WRITE_SECRET") secret := os.Getenv("WRITE_SECRET")
addr := os.Getenv("ADDR") addr := os.Getenv("ADDR")
chartMax := 0
if cfg, err := loadConfig("config.json"); err != nil { if cfg, err := loadConfig("config.json"); err != nil {
if !errors.Is(err, os.ErrNotExist) { if !errors.Is(err, os.ErrNotExist) {
log.Fatalf("load config: %v", err) log.Fatalf("load config: %v", err)
@@ -74,6 +84,9 @@ func main() {
if addr == "" { if addr == "" {
addr = cfg.Addr addr = cfg.Addr
} }
if cfg.ChartMaxWatts > 0 {
chartMax = cfg.ChartMaxWatts
}
} }
if secret == "" { if secret == "" {
log.Fatal("WRITE_SECRET is required") log.Fatal("WRITE_SECRET is required")
@@ -84,6 +97,7 @@ func main() {
app := &server{ app := &server{
secret: secret, secret: secret,
chartMax: chartMax,
store: &store{}, store: &store{},
} }
@@ -93,12 +107,14 @@ func main() {
type server struct { type server struct {
secret string secret string
chartMax int
store *store store *store
} }
type config struct { type config struct {
WriteSecret string `json:"write_secret"` WriteSecret string `json:"write_secret"`
Addr string `json:"addr"` Addr string `json:"addr"`
ChartMaxWatts int `json:"chart_max_watts"`
} }
func loadConfig(path string) (config, error) { func loadConfig(path string) (config, error) {
@@ -143,17 +159,17 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
if err := page.Execute(w, map[string]any{ if err := page.Execute(w, map[string]any{
"Watts": watts, "Watts": watts,
"UpdatedAt": updatedLabel, "UpdatedAt": updatedLabel,
"HistoryChart": buildHistoryChart(history), "HistoryChart": buildHistoryChart(history, s.chartMax),
}); err != nil { }); err != nil {
http.Error(w, "render error", http.StatusInternalServerError) http.Error(w, "render error", http.StatusInternalServerError)
} }
} }
func buildHistoryChart(history []sample) template.HTML { func buildHistoryChart(history []sample, maxWatts int) template.HTML {
const ( const (
width = 640 width = 640
height = 220 height = 220
padL = 52 padL = 72
padR = 18 padR = 18
padT = 18 padT = 18
padB = 42 padB = 42
@@ -164,10 +180,13 @@ func buildHistoryChart(history []sample) template.HTML {
} }
minV, maxV := 0, history[0].value minV, maxV := 0, history[0].value
if maxWatts > 0 {
maxV = maxWatts
}
start := history[0].at start := history[0].at
end := history[len(history)-1].at end := history[len(history)-1].at
for _, point := range history[1:] { for _, point := range history[1:] {
if point.value > maxV { if maxWatts <= 0 && point.value > maxV {
maxV = point.value maxV = point.value
} }
if point.at.Before(start) { if point.at.Before(start) {
@@ -193,6 +212,12 @@ func buildHistoryChart(history []sample) template.HTML {
for _, point := range history { for _, point := range history {
x := float64(padL) + plotW*point.at.Sub(start).Seconds()/span.Seconds() x := float64(padL) + plotW*point.at.Sub(start).Seconds()/span.Seconds()
y := float64(height-padB) - plotH*(float64(point.value-minV)/float64(maxV-minV)) 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)) points = append(points, fmt.Sprintf("%.1f,%.1f", x, y))
lastX, lastY = x, y lastX, lastY = x, y
} }
+36 -3
View File
@@ -66,7 +66,7 @@ func TestHandleWriteStoresValue(t *testing.T) {
func TestLoadConfig(t *testing.T) { func TestLoadConfig(t *testing.T) {
path := t.TempDir() + "/config.json" path := t.TempDir() + "/config.json"
if err := os.WriteFile(path, []byte(`{"write_secret":"secret","addr":":9090"}`), 0o600); err != nil { if err := os.WriteFile(path, []byte(`{"write_secret":"secret","addr":":9090","chart_max_watts":750}`), 0o600); err != nil {
t.Fatalf("write config: %v", err) t.Fatalf("write config: %v", err)
} }
@@ -80,10 +80,13 @@ func TestLoadConfig(t *testing.T) {
if cfg.Addr != ":9090" { if cfg.Addr != ":9090" {
t.Fatalf("addr = %q, want %q", cfg.Addr, ":9090") t.Fatalf("addr = %q, want %q", cfg.Addr, ":9090")
} }
if cfg.ChartMaxWatts != 750 {
t.Fatalf("chart_max_watts = %d, want %d", cfg.ChartMaxWatts, 750)
}
} }
func TestBuildHistoryChartEmpty(t *testing.T) { func TestBuildHistoryChartEmpty(t *testing.T) {
chart := buildHistoryChart(nil) chart := buildHistoryChart(nil, 0)
if !strings.Contains(string(chart), "No power history yet") { if !strings.Contains(string(chart), "No power history yet") {
t.Fatalf("empty chart did not contain fallback text: %s", string(chart)) t.Fatalf("empty chart did not contain fallback text: %s", string(chart))
} }
@@ -96,7 +99,7 @@ func TestBuildHistoryChartRendersPolyline(t *testing.T) {
{at: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), value: 150}, {at: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), value: 150},
} }
chart := buildHistoryChart(history) chart := buildHistoryChart(history, 0)
if !strings.Contains(string(chart), "<polyline") { if !strings.Contains(string(chart), "<polyline") {
t.Fatalf("chart did not contain polyline: %s", string(chart)) t.Fatalf("chart did not contain polyline: %s", string(chart))
} }
@@ -109,3 +112,33 @@ func TestBuildHistoryChartRendersPolyline(t *testing.T) {
t.Fatalf("chart did not include zero baseline: %s", string(chart)) t.Fatalf("chart did not include zero baseline: %s", string(chart))
} }
} }
func TestBuildHistoryChartUsesConfiguredMax(t *testing.T) {
history := []sample{{at: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), value: 250}}
chart := buildHistoryChart(history, 1000)
if !strings.Contains(string(chart), "1000 W") {
t.Fatalf("chart did not include configured max: %s", string(chart))
}
if strings.Contains(string(chart), "250 W") {
t.Fatalf("chart unexpectedly used sample max instead of configured max: %s", string(chart))
}
}
func TestStoreTrimsHistoryToMaxPoints(t *testing.T) {
s := &store{}
base := time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC)
s.mu.Lock()
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))
got := len(s.history)
s.mu.Unlock()
if 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)
}
}