From 843fa74036d2bc29aa900224af854e45a50cfb56 Mon Sep 17 00:00:00 2001 From: Domagoj Zecevic Date: Mon, 12 Oct 2026 13:17:55 +0200 Subject: [PATCH] feat(web): show live laundry status page fed by MQTT power readings --- .ai/HANDOFF.md | 14 ++++ .ai/REVIEW.md | 12 ++++ .ai/TASKS.md | 2 +- go.mod | 8 +++ go.sum | 8 +++ internal/sensor/mqtt.go | 52 ++++++++++++++ internal/sensor/sampler.go | 62 +++++++++++++++++ internal/sensor/sampler_test.go | 117 ++++++++++++++++++++++++++++++++ internal/web/handler.go | 30 ++++++++ internal/web/handler_test.go | 93 +++++++++++++++++++++++++ internal/web/static/index.html | 57 ++++++++++++++++ main.go | 103 ++++++++++++++++++++++++++++ main_test.go | 43 ++++++++++++ 13 files changed, 600 insertions(+), 1 deletion(-) create mode 100644 go.sum create mode 100644 internal/sensor/mqtt.go create mode 100644 internal/sensor/sampler.go create mode 100644 internal/sensor/sampler_test.go create mode 100644 internal/web/handler.go create mode 100644 internal/web/handler_test.go create mode 100644 internal/web/static/index.html create mode 100644 main.go create mode 100644 main_test.go diff --git a/.ai/HANDOFF.md b/.ai/HANDOFF.md index eecebf7..94db260 100644 --- a/.ai/HANDOFF.md +++ b/.ai/HANDOFF.md @@ -29,3 +29,17 @@ Append-only role handoff log. Each role adds one entry when its step is complete - Status: done. Commit message read from the T-001 next_task entry. - Included the existing roadmap and planning artifacts that define this cycle. + +### T-002 — next_task (dev/all_task) — 2026-10-12T11:17:06Z + +| Field | Value | +|-------|-------| +| Agent | codex | +| Summary | Added continuous MQTT sampling, current-status API, responsive page, environment configuration, and graceful shutdown | +| Validation | fmt/vet/test/race PASS; isolated MQTT and Chrome smoke PASS; review PASS | +| Commit | feat(web): show live laundry status page fed by MQTT power readings | +| Next Role | dev commit | + +### T-002 — commit_task (all_task) — 2026-10-12T11:17:06Z + +- Status: done. Commit message read from the T-002 next_task entry. diff --git a/.ai/REVIEW.md b/.ai/REVIEW.md index cfd0e92..ad65c92 100644 --- a/.ai/REVIEW.md +++ b/.ai/REVIEW.md @@ -10,3 +10,15 @@ - Environment retry: initial validation hit a read-only Go build cache; rerun with approved cache access passed. No code retries or weakened assertions. - E2E: NOT_CONFIGURED; this task contains only pure logic and parsing. - Verdict: PASS, approved for the per-task commit under `all_task`. + +## Task: T-002 + +### Review Round 1 — 2026-10-12T11:17:06Z + +- Findings: no required fixes; reviewed the complete diff against the sampler, MQTT, HTTP, page, and configuration plan. Comments document interval semantics and shutdown ownership; README remains assigned to T-003. +- Tests first: sampler, HTTP, and configuration tests failed on missing symbols before implementation, then passed unchanged. +- Validation: `go fmt ./...`, `go vet ./...`, `go test ./...`, `go test -race ./...`, and staged/unstaged diff checks PASS. +- E2E: no repository E2E command configured. Temporary `node /tmp/mqtt-washingmachine-smoke.mjs` PASS using an isolated Mosquitto container and real Chrome. Verified initial broker outage/retry, autonomous samples, 2.99/3.00 W boundary, malformed messages, gaps, reconnect/resubscribe, API, and SIGTERM exit 0. +- Browser: 360px viewport, all four messages/colors, white background, large status, black readings, 47.70W/-- formatting, 60s timer, failed fetch/recovery, and no horizontal overflow PASS. Screenshot: `/tmp/mqtt-washingmachine-mobile.png`. +- Implementation smoke retry: Docker reassigned a dynamically published broker port after restart; fixed the temporary harness to reserve an explicit port. All assertions retained; rerun passed. No product-code or review validation retries. +- Verdict: PASS, approved for the per-task commit under `all_task`. diff --git a/.ai/TASKS.md b/.ai/TASKS.md index 49d6cc1..4205985 100644 --- a/.ai/TASKS.md +++ b/.ai/TASKS.md @@ -21,5 +21,5 @@ Command expectations: | Task ID | Scope | Status | Acceptance Criteria | Evidence | Next Role | | --- | --- | --- | --- | --- | --- | | T-001 | Status evaluation logic (3-sample window, 3.00 W threshold, waiting/incomplete/running/done) and Tasmota payload parsing, with tests first | done | All state/boundary tests pass (2.99 idle, 3.00 active, gap -> incomplete, <3 samples -> waiting); payload parser handles valid, missing ENERGY/Power, and invalid JSON; fmt/vet/test pass | 2026-10-12T11:03:20Z: tests first, fmt/vet/test PASS; review PASS | none | -| T-002 | Per-minute sampler, MQTT subscription, HTTP `/api/status` and phone-first page, `main.go` env config | ready_for_implement | Sampler records valid/missing samples per tick; `/api/status` matches JSON contract; page has white background, large status, `47.70W` readings, viewport meta, 60 s refresh; fmt/vet/test pass | n/a | implement | +| T-002 | Per-minute sampler, MQTT subscription, HTTP `/api/status` and phone-first page, `main.go` env config | done | Sampler records valid/missing samples per tick; `/api/status` matches JSON contract; page has white background, large status, `47.70W` readings, viewport meta, 60 s refresh; fmt/vet/test pass | 2026-10-12T11:17:06Z: fmt/vet/test/race PASS; MQTT and 360px Chrome smoke PASS | none | | T-003 | Dockerfile, docker-compose example, .dockerignore, README documentation | ready_for_implement | Image builds (or noted if Docker unavailable); README documents rules, env vars, deployment, limitations; existing AI Workflow section untouched | n/a | implement | diff --git a/go.mod b/go.mod index 52f5177..cb205a4 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,11 @@ module mqtt_washingmachine go 1.26.0 + +require github.com/eclipse/paho.mqtt.golang v1.5.1 + +require ( + github.com/gorilla/websocket v1.5.3 // indirect + golang.org/x/net v0.44.0 // indirect + golang.org/x/sync v0.17.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9abe94c --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= +github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= diff --git a/internal/sensor/mqtt.go b/internal/sensor/mqtt.go new file mode 100644 index 0000000..481f1eb --- /dev/null +++ b/internal/sensor/mqtt.go @@ -0,0 +1,52 @@ +package sensor + +import ( + "crypto/rand" + "log/slog" + "time" + + mqtt "github.com/eclipse/paho.mqtt.golang" +) + +// MQTTConfig selects the broker, sensor topic, and optional broker credentials. +type MQTTConfig struct { + Broker string + Topic string + Username string + Password string +} + +// StartMQTT connects in the background, retrying initial failures and reconnecting +// after outages. Every connection subscribes again because sessions are clean. +// The caller must Disconnect the returned client during shutdown. +func StartMQTT(config MQTTConfig, sampler *Sampler, logger *slog.Logger) mqtt.Client { + options := mqtt.NewClientOptions(). + AddBroker(config.Broker). + SetClientID("laundry-" + rand.Text()[:12]). + SetUsername(config.Username). + SetPassword(config.Password). + SetAutoReconnect(true). + SetConnectRetry(true). + SetConnectRetryInterval(5 * time.Second). + SetConnectTimeout(5 * time.Second) + options.SetOnConnectHandler(func(client mqtt.Client) { + token := client.Subscribe(config.Topic, 0, func(_ mqtt.Client, message mqtt.Message) { + if err := sampler.Receive(message.Payload()); err != nil { + logger.Warn("Ignoring invalid sensor payload", "error", err) + } + }) + if !token.WaitTimeout(10 * time.Second) { + logger.Error("MQTT subscription timed out") + } else if err := token.Error(); err != nil { + logger.Error("MQTT subscription failed", "error", err) + } else { + logger.Info("MQTT sensor subscription ready", "topic", config.Topic) + } + }) + options.SetConnectionLostHandler(func(_ mqtt.Client, err error) { + logger.Warn("MQTT connection lost; reconnecting", "error", err) + }) + client := mqtt.NewClient(options) + client.Connect() + return client +} diff --git a/internal/sensor/sampler.go b/internal/sensor/sampler.go new file mode 100644 index 0000000..7f91bee --- /dev/null +++ b/internal/sensor/sampler.go @@ -0,0 +1,62 @@ +package sensor + +import ( + "context" + "sync" + "time" + + "mqtt_washingmachine/internal/status" +) + +// Sampler collects the latest valid message in each interval, independently of +// HTTP requests. Its zero value is ready to use; all methods are concurrency-safe. +type Sampler struct { + mu sync.Mutex + window status.Window + power float64 + receivedAt time.Time + received bool +} + +// Receive updates the pending measurement. Invalid payloads leave it untouched. +func (s *Sampler) Receive(payload []byte) error { + power, err := ParsePower(payload) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + s.power, s.receivedAt, s.received = power, time.Now(), true + return nil +} + +// Run records one sample per tick, then clears the received flag. An interval +// without valid messages is missing; a previous measurement is never reused. +// The caller supplies the ticker and runs only one Run loop per sampler. +func (s *Sampler) Run(ctx context.Context, ticks <-chan time.Time) { + for { + select { + case <-ctx.Done(): + return + case _, ok := <-ticks: + if !ok { + return + } + s.mu.Lock() + sample := status.Sample{} + if s.received { + sample = status.Sample{Power: s.power, Valid: true} + } + s.window.Add(sample) + s.received = false + s.mu.Unlock() + } + } +} + +// Status returns an independent snapshot of the current window and its state. +func (s *Sampler) Status() status.Result { + s.mu.Lock() + defer s.mu.Unlock() + return status.Evaluate(s.window.Readings()) +} diff --git a/internal/sensor/sampler_test.go b/internal/sensor/sampler_test.go new file mode 100644 index 0000000..f249c6e --- /dev/null +++ b/internal/sensor/sampler_test.go @@ -0,0 +1,117 @@ +package sensor + +import ( + "context" + "reflect" + "sync" + "testing" + "testing/synctest" + "time" + + "mqtt_washingmachine/internal/status" +) + +func TestSamplerIntervals(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + var sampler Sampler + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + ticks := make(chan time.Time) + done := make(chan struct{}) + go func() { sampler.Run(ctx, ticks); close(done) }() + tick := func() { ticks <- time.Now(); synctest.Wait() } + receive := func(payload string) { + t.Helper() + if err := sampler.Receive([]byte(payload)); err != nil { + t.Fatal(err) + } + } + if got := sampler.Status(); got.State != "waiting" || len(got.Readings) != 0 { + t.Fatalf("startup: %+v", got) + } + receive(`{"ENERGY":{"Power":100}}`) + receive(`{"ENERGY":{"Power":47.7}}`) + if err := sampler.Receive([]byte(`{"ENERGY":{}}`)); err == nil { + t.Fatal("invalid payload accepted") + } + if got := sampler.Status(); len(got.Readings) != 0 { + t.Fatal("message recorded before tick") + } + tick() + if got := sampler.Status(); !reflect.DeepEqual(got.Readings, []status.Sample{{Power: 47.7, Valid: true}}) { + t.Fatalf("latest valid measurement lost: %+v", got) + } + if err := sampler.Receive([]byte(`broken`)); err == nil { + t.Fatal("malformed payload accepted") + } + tick() + receive(`{"ENERGY":{"Power":0}}`) + tick() + want := []status.Sample{{Power: 47.7, Valid: true}, {}, {Valid: true}} + if got := sampler.Status(); got.State != "incomplete" || !reflect.DeepEqual(got.Readings, want) { + t.Fatalf("missing interval: %+v", got) + } + for range 2 { + receive(`{"ENERGY":{"Power":2.99}}`) + tick() + } + if got := sampler.Status(); got.State != "done" { + t.Fatalf("gap did not age out: %+v", got) + } + receive(`{"ENERGY":{"Power":3.00}}`) + tick() + if got := sampler.Status(); got.State != "running" { + t.Fatalf("threshold: %+v", got) + } + cancel() + synctest.Wait() + select { + case <-done: + default: + t.Fatal("Run did not stop on cancellation") + } + }) +} + +func TestSamplerClosedTicks(t *testing.T) { + var sampler Sampler + ticks := make(chan time.Time) + close(ticks) + sampler.Run(t.Context(), ticks) + if len(sampler.Status().Readings) != 0 { + t.Fatal("closed ticks created a sample") + } +} + +func TestSamplerConcurrentAccess(t *testing.T) { + var sampler Sampler + ctx, cancel := context.WithCancel(t.Context()) + ticks := make(chan time.Time) + done := make(chan struct{}) + go func() { sampler.Run(ctx, ticks); close(done) }() + var workers sync.WaitGroup + for range 10 { + workers.Go(func() { + for range 100 { + if err := sampler.Receive([]byte(`{"ENERGY":{"Power":1}}`)); err != nil { + t.Error(err) + } + snapshot := sampler.Status() + if len(snapshot.Readings) > 0 { + snapshot.Readings[0].Power = 999 + } + } + }) + } + for range 100 { + ticks <- time.Now() + } + workers.Wait() + cancel() + <-done + for _, sample := range sampler.Status().Readings { + if sample.Power == 999 { + t.Fatal("snapshot changed shared state") + } + } +} diff --git a/internal/web/handler.go b/internal/web/handler.go new file mode 100644 index 0000000..c80eb28 --- /dev/null +++ b/internal/web/handler.go @@ -0,0 +1,30 @@ +// Package web serves the laundry status API and embedded phone-first page. +package web + +import ( + _ "embed" + "encoding/json" + "net/http" + + "mqtt_washingmachine/internal/status" +) + +//go:embed static/index.html +var indexHTML []byte + +// NewHandler fetches a fresh snapshot for each API request. Disabling caching +// keeps reloads and polling from showing a previous laundry state. +func NewHandler(snapshot func() status.Result) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Write(indexHTML) + }) + mux.HandleFunc("GET /api/status", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + json.NewEncoder(w).Encode(snapshot()) + }) + return mux +} diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go new file mode 100644 index 0000000..3d63710 --- /dev/null +++ b/internal/web/handler_test.go @@ -0,0 +1,93 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "mqtt_washingmachine/internal/status" +) + +func TestStatusAPI(t *testing.T) { + for _, tt := range []struct { + name, state, message string + readings []status.Sample + }{ + {"startup", "waiting", "Waiting for data...", []status.Sample{}}, + {"waiting", "waiting", "Waiting for data...", []status.Sample{{Power: 47.7, Valid: true}}}, + {"incomplete", "incomplete", "Incomplete data", []status.Sample{{Power: 47.7, Valid: true}, {}, {Valid: true}}}, + {"running", "running", "Laundry not yet done", []status.Sample{{Valid: true}, {Power: 3, Valid: true}, {Valid: true}}}, + {"done", "done", "Laundry done", []status.Sample{{Power: 2.99, Valid: true}, {Valid: true}, {Valid: true}}}, + } { + t.Run(tt.name, func(t *testing.T) { + handler := NewHandler(func() status.Result { return status.Evaluate(tt.readings) }) + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/status", nil)) + if response.Code != http.StatusOK { + t.Fatalf("status = %d", response.Code) + } + if got := response.Header().Get("Content-Type"); got != "application/json" { + t.Errorf("content type = %q", got) + } + if got := response.Header().Get("Cache-Control"); got != "no-store" { + t.Errorf("cache control = %q", got) + } + var got status.Result + if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + want := status.Result{State: tt.state, Message: tt.message, Readings: tt.readings} + if !reflect.DeepEqual(got, want) { + t.Fatalf("body = %+v; want %+v", got, want) + } + }) + } +} + +func TestPageAndRouting(t *testing.T) { + handler := NewHandler(func() status.Result { return status.Evaluate(nil) }) + page := httptest.NewRecorder() + handler.ServeHTTP(page, httptest.NewRequest(http.MethodGet, "/", nil)) + if page.Code != http.StatusOK || !strings.HasPrefix(page.Header().Get("Content-Type"), "text/html") { + t.Fatalf("page response: %+v", page.Result()) + } + for _, text := range []string{`name="viewport"`, "Waiting for data...", "/api/status", "60000", "toFixed(2)", "--", "clamp("} { + if !strings.Contains(page.Body.String(), text) { + t.Errorf("page missing %q", text) + } + } + for _, tt := range []struct { + method, path string + code int + }{ + {http.MethodGet, "/missing", http.StatusNotFound}, + {http.MethodPost, "/api/status", http.StatusMethodNotAllowed}, + {http.MethodPost, "/", http.StatusMethodNotAllowed}, + } { + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(tt.method, tt.path, nil)) + if response.Code != tt.code { + t.Errorf("%s %s = %d; want %d", tt.method, tt.path, response.Code, tt.code) + } + } +} + +func TestStatusAPIReadsCurrentSnapshot(t *testing.T) { + var window status.Window + handler := NewHandler(func() status.Result { return status.Evaluate(window.Readings()) }) + for range 3 { + window.Add(status.Sample{Valid: true}) + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/status", nil)) + var got status.Result + if err := json.Unmarshal(response.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got.Readings, window.Readings()) { + t.Fatal("handler returned stale readings") + } + } +} diff --git a/internal/web/static/index.html b/internal/web/static/index.html new file mode 100644 index 0000000..49b819f --- /dev/null +++ b/internal/web/static/index.html @@ -0,0 +1,57 @@ + + + + + + Laundry status + + + +
+

Laundry status

+
+

Waiting for data...

+

Latest power readings · oldest first

+
    +
    + +
    + + + diff --git a/main.go b/main.go new file mode 100644 index 0000000..923dbb4 --- /dev/null +++ b/main.go @@ -0,0 +1,103 @@ +// Command mqtt_washingmachine serves laundry status from sampled MQTT power data. +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "mqtt_washingmachine/internal/sensor" + "mqtt_washingmachine/internal/web" +) + +type config struct { + sensor.MQTTConfig + HTTPAddr string + SampleInterval time.Duration +} + +// Empty variables use defaults. SAMPLE_INTERVAL accepts positive Go durations; +// it controls backend sampling only, while the page always polls every 60 s. +func loadConfig() (config, error) { + env := func(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback + } + interval, err := time.ParseDuration(env("SAMPLE_INTERVAL", "60s")) + if err != nil || interval <= 0 { + return config{}, errors.New("SAMPLE_INTERVAL must be a positive Go duration, such as 60s") + } + return config{ + MQTTConfig: sensor.MQTTConfig{ + Broker: env("MQTT_BROKER", "tcp://192.168.1.12:1883"), + Topic: env("MQTT_TOPIC", "tele/tasmota_washingmashine_C21183/SENSOR"), + Username: os.Getenv("MQTT_USERNAME"), + Password: os.Getenv("MQTT_PASSWORD"), + }, + HTTPAddr: env("HTTP_ADDR", ":8080"), + SampleInterval: interval, + }, nil +} + +func run(ctx context.Context, cfg config) error { + listener, err := net.Listen("tcp", cfg.HTTPAddr) + if err != nil { + return fmt.Errorf("listen for HTTP: %w", err) + } + defer listener.Close() + ctx, cancel := context.WithCancel(ctx) + ticker := time.NewTicker(cfg.SampleInterval) + defer ticker.Stop() + var sampler sensor.Sampler + done := make(chan struct{}) + go func() { + defer close(done) + sampler.Run(ctx, ticker.C) + }() + defer func() { cancel(); <-done }() + client := sensor.StartMQTT(cfg.MQTTConfig, &sampler, slog.Default()) + defer client.Disconnect(250) + server := &http.Server{ + Handler: web.NewHandler(sampler.Status), + ReadHeaderTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } + errorsCh := make(chan error, 1) + go func() { errorsCh <- server.Serve(listener) }() + slog.Info("Laundry status server listening", "address", listener.Addr().String()) + select { + case err := <-errorsCh: + return err + case <-ctx.Done(): + shutdownCtx, stop := context.WithTimeout(context.Background(), 5*time.Second) + defer stop() + if err := server.Shutdown(shutdownCtx); err != nil { + server.Close() + return fmt.Errorf("HTTP shutdown: %w", err) + } + return nil + } +} + +func main() { + cfg, err := loadConfig() + if err == nil { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + err = run(ctx, cfg) + } + if err != nil { + slog.Error("Laundry status service stopped", "error", err) + os.Exit(1) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..db38591 --- /dev/null +++ b/main_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "testing" + "time" +) + +func TestLoadConfigDefaults(t *testing.T) { + for _, key := range []string{"MQTT_BROKER", "MQTT_TOPIC", "MQTT_USERNAME", "MQTT_PASSWORD", "HTTP_ADDR", "SAMPLE_INTERVAL"} { + t.Setenv(key, "") + } + got, err := loadConfig() + if err != nil { + t.Fatal(err) + } + if got.Broker != "tcp://192.168.1.12:1883" || got.Topic != "tele/tasmota_washingmashine_C21183/SENSOR" || got.HTTPAddr != ":8080" || got.SampleInterval != time.Minute || got.Username != "" || got.Password != "" { + t.Fatalf("defaults: %+v", got) + } +} + +func TestLoadConfigOverrides(t *testing.T) { + for key, value := range map[string]string{"MQTT_BROKER": "tcp://localhost:1884", "MQTT_TOPIC": "test/power", "MQTT_USERNAME": "tester", "MQTT_PASSWORD": "test-password", "HTTP_ADDR": "127.0.0.1:9090", "SAMPLE_INTERVAL": "250ms"} { + t.Setenv(key, value) + } + got, err := loadConfig() + if err != nil { + t.Fatal(err) + } + if got.Broker != "tcp://localhost:1884" || got.Topic != "test/power" || got.Username != "tester" || got.Password != "test-password" || got.HTTPAddr != "127.0.0.1:9090" || got.SampleInterval != 250*time.Millisecond { + t.Fatal("environment overrides not applied") + } +} + +func TestLoadConfigRejectsInvalidInterval(t *testing.T) { + for _, interval := range []string{"nonsense", "0s", "-1s", "60"} { + t.Run(interval, func(t *testing.T) { + t.Setenv("SAMPLE_INTERVAL", interval) + if _, err := loadConfig(); err == nil { + t.Fatalf("accepted SAMPLE_INTERVAL=%q", interval) + } + }) + } +}