13 changed files with 600 additions and 1 deletions
@ -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 |
|||
) |
|||
|
|||
@ -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= |
|||
@ -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 |
|||
} |
|||
@ -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()) |
|||
} |
|||
@ -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") |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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") |
|||
} |
|||
} |
|||
} |
|||
@ -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> |
|||
@ -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) |
|||
} |
|||
} |
|||
@ -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…
Reference in new issue