You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

5.8 KiB

Plan

Status: ready_for_implement

Goal: implement ROADMAP.md Priority 2 — the page refreshes itself whenever a new valid MQTT power message arrives, using Server-Sent Events (SSE). T-001..T-003 (Priority 1) are done and stay unchanged in behavior.

Scope

  • Standard library only; no new dependencies.
  • Out of scope: live-reading line, WebSocket, changes to the status rule, JSON contract of /api/status, or 1-minute sampling.

Design decisions (from ROADMAP.md Priority 2)

  • Event source: a valid payload accepted by Sampler.Receive (about every 10 s). Invalid payloads return an error and never notify.
  • Hub: new internal/sensor/hub.go with a concurrency-safe Hub (zero value usable):
    • Subscribe() (events <-chan struct{}, cancel func()): each subscriber gets a channel with buffer 1.
    • Notify(): non-blocking send to every subscriber (drop if the buffer is full, so a slow client never blocks MQTT handling and bursts coalesce).
    • cancel removes the subscriber; safe to call more than once.
  • Sampler gets an optional exported field OnReceive func(), set before use (documented as such), called after a valid payload is stored and the mutex is released. Zero value stays ready to use. main.go sets sampler.OnReceive = hub.Notify.
  • Endpoint GET /api/events (text/event-stream, Cache-Control: no-store):
    • On connect: flush headers and send an initial event: update so the client syncs immediately (covers reconnects).
    • Per notification: write event: update\ndata: {}\n\n and flush.
    • Keep-alive: SSE comment line : keep-alive\n\n every 25 s (interval a package-level variable so tests can shorten it).
    • Clear the write deadline for this stream with http.NewResponseController(w).SetWriteDeadline(time.Time{}) so the server-wide 10 s WriteTimeout does not cut it; keep WriteTimeout as is for other routes. If the ResponseWriter does not support flushing, return 500.
    • Exit on r.Context().Done() (client gone or server shutdown) and call cancel.
  • Graceful shutdown: server.Shutdown waits for active handlers; SSE handlers must also stop on shutdown. Register server.RegisterOnShutdown(cancelStreams) with a context the handler selects on (e.g. a base context passed to NewHandler), so shutdown is not delayed to the 5 s timeout.
  • NewHandler signature becomes NewHandler(snapshot func() status.Result, events *sensor.Hub); web imports sensor only for the Hub type (or accept an interface Subscribe() (<-chan struct{}, func()) to avoid the import; prefer the interface).
  • Page (index.html):
    • Open new EventSource('/api/events'); on update call refresh(). EventSource reconnects automatically; the open event after a reconnect triggers a refresh via the initial update.
    • Keep setInterval(refresh, 60000) as a fallback. Coalesce overlapping refreshes (skip or reuse if one is in flight) so bursts do not stack requests.
    • If EventSource is unavailable, rely on polling only.

Acceptance Criteria

  • A valid MQTT message triggers an SSE update event to all connected clients within the same moment; the page then re-fetches /api/status.
  • Invalid payloads produce no event.
  • A new connection receives an immediate update; idle connections receive a : keep-alive comment at the configured interval.
  • The stream survives longer than the 10 s WriteTimeout; other routes keep it.
  • A slow or stuck subscriber never blocks Receive/MQTT handling or other subscribers.
  • Disconnected clients are removed from the hub (no goroutine or subscriber leak); shutdown closes streams promptly.
  • /api/status JSON, status rule, and 60 s polling fallback unchanged.
  • go fmt ./..., go vet ./..., go test ./... (also -race for sensor and web) pass.

Tasks

T-004 — SSE push so the page refreshes on each MQTT message

Tests first, then implementation.

  • internal/sensor/hub_test.go, internal/sensor/hub.go: subscribe/notify delivery to multiple subscribers, coalescing when buffer is full (Notify never blocks), cancel removes subscriber and is idempotent, concurrent use under -race.
  • internal/sensor/sampler_test.go, internal/sensor/sampler.go: OnReceive called once per valid payload, not called for invalid payload, nil OnReceive is safe.
  • internal/web/handler_test.go, internal/web/handler.go: /api/events headers (text/event-stream, no-store), initial update, update after a notification, keep-alive with a shortened interval, handler returns and cancels on client context cancel, non-flushable writer -> 500; existing tests updated for the new NewHandler signature; page test asserts EventSource and /api/events are present and 60000 fallback remains.
  • internal/web/static/index.html: EventSource wiring, in-flight refresh coalescing, polling fallback.
  • main.go, main_test.go: create the hub, set sampler.OnReceive, pass to NewHandler, shutdown handling for streams; keep WriteTimeout; test that shutdown completes promptly with an open stream if feasible.
  • Documentation (explicit scope): README.md — describe GET /api/events (event format, keep-alive), that the page refreshes on each MQTT message with 60 s polling as fallback, and update the statements at lines ~13 and ~76 that say the page only refreshes every 60 s; note that reverse proxies must not buffer text/event-stream. Update code comments in main.go (loadConfig comment says the page always polls every 60 s) and package docs for Hub and OnReceive. ROADMAP.md already updated.
  • Files to change: internal/sensor/hub.go, internal/sensor/hub_test.go, internal/sensor/sampler.go, internal/sensor/sampler_test.go, internal/web/handler.go, internal/web/handler_test.go, internal/web/static/index.html, main.go, main_test.go, README.md. Stage new files with git add.
  • Suggested commit: feat(web): refresh the laundry page instantly on each MQTT message via server-sent events