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
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.gowith a concurrency-safeHub(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).cancelremoves the subscriber; safe to call more than once.
Samplergets an optional exported fieldOnReceive 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.gosetssampler.OnReceive = hub.Notify.- Endpoint
GET /api/events(text/event-stream,Cache-Control: no-store):- On connect: flush headers and send an initial
event: updateso the client syncs immediately (covers reconnects). - Per notification: write
event: update\ndata: {}\n\nand flush. - Keep-alive: SSE comment line
: keep-alive\n\nevery 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 sWriteTimeoutdoes not cut it; keepWriteTimeoutas is for other routes. If the ResponseWriter does not support flushing, return 500. - Exit on
r.Context().Done()(client gone or server shutdown) and callcancel.
- On connect: flush headers and send an initial
- Graceful shutdown:
server.Shutdownwaits for active handlers; SSE handlers must also stop on shutdown. Registerserver.RegisterOnShutdown(cancelStreams)with a context the handler selects on (e.g. a base context passed toNewHandler), so shutdown is not delayed to the 5 s timeout. NewHandlersignature becomesNewHandler(snapshot func() status.Result, events *sensor.Hub);webimportssensoronly for the Hub type (or accept an interfaceSubscribe() (<-chan struct{}, func())to avoid the import; prefer the interface).- Page (
index.html):- Open
new EventSource('/api/events'); onupdatecallrefresh().EventSourcereconnects automatically; theopenevent after a reconnect triggers a refresh via the initialupdate. - 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
EventSourceis unavailable, rely on polling only.
- Open
Acceptance Criteria
- A valid MQTT message triggers an SSE
updateevent 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-alivecomment 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/statusJSON, status rule, and 60 s polling fallback unchanged.go fmt ./...,go vet ./...,go test ./...(also-racefor 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:OnReceivecalled once per valid payload, not called for invalid payload, nilOnReceiveis safe.internal/web/handler_test.go,internal/web/handler.go:/api/eventsheaders (text/event-stream,no-store), initialupdate,updateafter 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 newNewHandlersignature; page test assertsEventSourceand/api/eventsare present and60000fallback remains.internal/web/static/index.html: EventSource wiring, in-flight refresh coalescing, polling fallback.main.go,main_test.go: create the hub, setsampler.OnReceive, pass toNewHandler, shutdown handling for streams; keepWriteTimeout; test that shutdown completes promptly with an open stream if feasible.- Documentation (explicit scope):
README.md— describeGET /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 buffertext/event-stream. Update code comments inmain.go(loadConfigcomment says the page always polls every 60 s) and package docs forHubandOnReceive.ROADMAP.mdalready 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 withgit add. - Suggested commit:
feat(web): refresh the laundry page instantly on each MQTT message via server-sent events