Browse Source

feat(web): show live laundry status page fed by MQTT power readings

main
Domagoj Zecevic 3 weeks ago
parent
commit
843fa74036
  1. 14
      .ai/HANDOFF.md
  2. 12
      .ai/REVIEW.md
  3. 2
      .ai/TASKS.md
  4. 8
      go.mod
  5. 8
      go.sum
  6. 52
      internal/sensor/mqtt.go
  7. 62
      internal/sensor/sampler.go
  8. 117
      internal/sensor/sampler_test.go
  9. 30
      internal/web/handler.go
  10. 93
      internal/web/handler_test.go
  11. 57
      internal/web/static/index.html
  12. 103
      main.go
  13. 43
      main_test.go

14
.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. - Status: done. Commit message read from the T-001 next_task entry.
- Included the existing roadmap and planning artifacts that define this cycle. - 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.

12
.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. - 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. - E2E: NOT_CONFIGURED; this task contains only pure logic and parsing.
- Verdict: PASS, approved for the per-task commit under `all_task`. - 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`.

2
.ai/TASKS.md

@ -21,5 +21,5 @@ Command expectations:
| Task ID | Scope | Status | Acceptance Criteria | Evidence | Next Role | | 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-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 | | 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 |

8
go.mod

@ -1,3 +1,11 @@
module mqtt_washingmachine module mqtt_washingmachine
go 1.26.0 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
)

8
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=

52
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
}

62
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())
}

117
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")
}
}
}

30
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
}

93
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")
}
}
}

57
internal/web/static/index.html

@ -0,0 +1,57 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Laundry status</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; background: #fff; color: #111; font-family: system-ui, sans-serif; }
main { max-width: 44rem; margin: 0 auto; padding: 3rem 1.25rem; text-align: center; }
h1 { margin: 1rem 0 2rem; font-size: clamp(3rem, 12vw, 5rem); line-height: 1.1; overflow-wrap: anywhere; }
.running { color: #b91c1c; }
.waiting, .incomplete, .unavailable { color: #666; }
.label { color: #666; font-size: 1rem; }
#readings { display: flex; flex-wrap: wrap; justify-content: center; gap: 1.25rem; padding: 0; list-style: none; color: #000; font-size: 1rem; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
#readings li { min-width: 0; max-width: 100%; }
</style>
</head>
<body>
<main>
<p class="label">Laundry status</p>
<section aria-live="polite" aria-atomic="true">
<h1 id="status" class="waiting">Waiting for data...</h1>
<p class="label">Latest power readings · oldest first</p>
<ul id="readings" aria-label="Latest power readings"></ul>
</section>
<noscript>Enable JavaScript to see the current laundry status.</noscript>
</main>
<script>
const statusText = document.getElementById('status');
const readings = document.getElementById('readings');
async function refresh() {
try {
const response = await fetch('/api/status', { cache: 'no-store', signal: AbortSignal.timeout(10000) });
if (!response.ok) throw new Error('Status request failed');
const data = await response.json();
const items = data.readings.map(reading => {
const item = document.createElement('li');
item.textContent = reading.valid ? reading.power.toFixed(2) + 'W' : '--';
return item;
});
statusText.textContent = data.message;
statusText.className = data.state;
readings.replaceChildren(...items);
} catch {
statusText.textContent = 'Status unavailable';
statusText.className = 'unavailable';
readings.replaceChildren();
}
}
refresh();
setInterval(refresh, 60000);
</script>
</body>
</html>

103
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)
}
}

43
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)
}
})
}
}
Loading…
Cancel
Save