Goal: implement the scope defined in `ROADMAP.md` — a phone-first webpage, served by a small Go backend in Docker, that shows whether the laundry is done based on the last 3 one-minute power samples from `tele/tasmota_washingmashine_C21183/SENSOR`.
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
## Scope
- Go module `mqtt_washingmachine` (Go 1.26), standard library plus `github.com/eclipse/paho.mqtt.golang` for MQTT.
- Standard library only; no new dependencies.
- Layout:
- Out of scope: live-reading line, WebSocket, changes to the status rule, JSON contract of `/api/status`, or 1-minute sampling.
- `main.go`: config from env, wiring, graceful shutdown.
- `internal/status/`: pure logic (sample window, status evaluation).
- Event source: a valid payload accepted by `Sampler.Receive` (about every 10 s). Invalid payloads return an error and never notify.
- `internal/web/`: HTTP handlers and embedded static page (`go:embed`).
- Hub: new `internal/sensor/hub.go` with a concurrency-safe `Hub` (zero value usable):
- Docker packaging and documentation.
- `Subscribe() (events <-chan struct{}, cancel func())`: each subscriber gets a channel with buffer 1.
- Out of scope: persistence, TLS/auth, history beyond 3 samples, multiple devices.
- `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.
## Design decisions (from ROADMAP.md)
- `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`.
- Threshold: 3.00 W. A sample is "idle" only if `power < 3.0`; `>= 3.0` is "active".
- Sampling: the MQTT handler stores only the latest `ENERGY.Power` value and its receive time. A ticker fires every `SAMPLE_INTERVAL` (default 60s). At each tick: if a message was received since the previous tick, record its power as a sample; otherwise record a missing sample. Then clear the "received" flag.
- On connect: flush headers and send an initial `event: update` so the client syncs immediately (covers reconnects).
- Window: ring of the last 3 samples (oldest first). Each sample is `{Power float64, Valid bool}`.
- Per notification: write `event: update\ndata: {}\n\n` and flush.
- Status evaluation (first match wins):
- Keep-alive: SSE comment line `: keep-alive\n\n` every 25 s (interval a package-level variable so tests can shorten it).
1. fewer than 3 samples recorded since start -> `waiting`, "Waiting for data...", grey
- 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.
2. any of the last 3 samples missing -> `incomplete`, "Incomplete data", grey
- Exit on `r.Context().Done()` (client gone or server shutdown) and call `cancel`.
3. any sample `>= 3.0` -> `running`, "Laundry not yet done", red
- 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.
4. otherwise (all 3 `< 3.0`) -> `done`, "Laundry done", default text color
- `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).
- JSON contract for `GET /api/status`:
- Page (`index.html`):
```json
- 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`.
{"state":"running","message":"Laundry not yet done","readings":[{"power":47.7,"valid":true},{"power":0,"valid":false}]}
- 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.
`readings` has 0–3 entries, oldest first. The page renders the display text, color class, and `47.70W` / `--` formatting.
- MQTT client uses auto-reconnect and re-subscribes in the `OnConnect` handler.
## Acceptance Criteria
## Acceptance Criteria
- `GET /` returns a white-background page with a viewport meta tag, a very large status text, and the last 3 readings below it in normal-size black text formatted like `47.70W`; missing readings show `--`.
- A valid MQTT message triggers an SSE `update` event to all connected clients within the same moment; the page then re-fetches `/api/status`.
- The page fetches `/api/status` on load and every 60 s; no horizontal scroll at 360 px width.
- Invalid payloads produce no event.
- Status states and colors match the table in `ROADMAP.md` exactly, including `>= 3.00 W` = not done.
- A new connection receives an immediate `update`; idle connections receive a `: keep-alive` comment at the configured interval.
- The backend collects samples continuously with no page open; a page load immediately reflects the current state.
- The stream survives longer than the 10 s `WriteTimeout`; other routes keep it.
- A minute with no MQTT message produces a missing sample and `incomplete` state.
- A slow or stuck subscriber never blocks `Receive`/MQTT handling or other subscribers.
- Malformed payloads or payloads without `ENERGY.Power` are ignored (logged), without crashing or changing the latest value.
- Disconnected clients are removed from the hub (no goroutine or subscriber leak); shutdown closes streams promptly.
- `docker build` succeeds and the container runs with env-var configuration; README documents setup.
- `/api/status` JSON, status rule, and 60 s polling fallback unchanged.
- `go fmt ./...`, `go vet ./...`, `go test ./...` all pass.
- `go fmt ./...`, `go vet ./...`, `go test ./...`(also `-race` for sensor and web) pass.
## Tasks
## Tasks
### T-001 — Status logic and Tasmota payload parsing
### T-004 — SSE push so the page refreshes on each MQTT message
Tests first, then implementation.
- `internal/status/status_test.go`, `internal/status/status.go`: window of 3, `Evaluate`, boundary tests (2.99 -> idle, 3.00 -> active, 0 W all three -> done, mixed -> running, gap -> incomplete, <3samples-> waiting).
- `internal/sensor/payload_test.go`, `internal/sensor/payload.go`: parse the sample payload from the roadmap (`Power` 139.60 etc.), missing `ENERGY`, missing `Power`, invalid JSON.
- `go.mod` (module init, no external deps yet in this task).
- Files to change: `go.mod`, `internal/status/*`, `internal/sensor/payload*.go`.
- Documentation: package doc comments; no README change yet.
### T-002 — Sampler, MQTT subscription, HTTP server and page
Tests first, then implementation.
Tests first, then implementation.
- `internal/sensor/sampler_test.go`, `internal/sensor/sampler.go`: latest-value store plus ticker-driven sampling into the window, with an injectable tick channel/clock for deterministic tests (message between ticks -> valid sample, none -> missing).
- `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/mqtt.go`: paho client, auto-reconnect, subscribe in `OnConnect`, feeds payloads to the parser then the sampler (thin glue, not unit-tested against a real broker).
- `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`, `internal/web/static/index.html`: `/` serves the embedded page, `/api/status` returns the JSON contract (test with `httptest`, including state/message/readings per state). Page styling: white background, `font-size` about 3rem+ bold for status via `clamp()`, readings in black at normal size, red/grey classes, `meta viewport`, 60 s `setInterval` fetch, graceful display if the fetch fails.
- `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.
- `main.go`: env config with defaults, wiring, signal-based shutdown.
- `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: code comments; README deferred to T-003.
- 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`.
### T-003 — Docker packaging and README
- Suggested commit: `feat(web): refresh the laundry page instantly on each MQTT message via server-sent events`
- `Dockerfile` (multi-stage: golang builder with `CGO_ENABLED=0`, final `scratch` or `distroless/static` image, `EXPOSE 8080`).
- `docker-compose.yml` example with the env vars from this plan.
- `.dockerignore`.
- `README.md`: replace the project TODOs (Overview, Getting Started) with what it does, status rules, env var table, `docker compose up` instructions, the Tasmota `TelePeriod` note (10 s, any value works since sampling is per minute), and limitations (in-memory state, LAN only, no auth). Keep the existing AI Workflow section untouched. English only.
- Files to change: `Dockerfile`, `docker-compose.yml`, `.dockerignore`, `README.md`.
- Validation extra: `docker build .` if Docker is available; otherwise note it in the handoff.
## Validation
- `go fmt ./...`
- `go vet ./...`
- `go test ./...`
- Manual check after T-002: run with `HTTP_ADDR=:8080`, publish test payloads with `mosquitto_pub` (if available), open `/` at a phone-width viewport.
## Commit subjects (suggested)
- T-001: `feat(status): evaluate laundry state from the last three power samples`
- T-002: `feat(web): show live laundry status page fed by MQTT power readings`
- T-003: `feat(docker): package laundry status service with Docker and README`
- Environment retry during implementation: Docker build metadata was read-only under the sandbox; approved rerun built successfully. No review validation retries or weakened assertions.
- Environment retry during implementation: Docker build metadata was read-only under the sandbox; approved rerun built successfully. No review validation retries or weakened assertions.
- Workflow limitation: `aide pr --dry-run` rejected the current `main` branch. AGENTS.md requires working in the current branch, so no branch change or PR creation was performed.
- Workflow limitation: `aide pr --dry-run` rejected the current `main` branch. AGENTS.md requires working in the current branch, so no branch change or PR creation was performed.
- Verdict: PASS, approved for the per-task commit under `all_task`.
- Verdict: PASS, approved for the per-task commit under `all_task`.
## Task: T-004
### Review Round 1 — 2026-10-12T13:17:23Z
- Mechanical retry 1 (`go test -race ./...`): the test recorder's body read before advancing synthetic time was not synchronized with the subsequent keep-alive write. Synchronize the recorder fixture's writes and body reads; retain all assertions and expected behavior. No product race was reported.
- Retry 1 result: fmt/vet/test/race PASS after synchronizing the test recorder; no assertions weakened or removed.
### Final verification — 2026-10-12T13:18:18Z
- Findings: no required fixes or semantic concerns. Reviewed the staged and unstaged diff against T-004; status rules, JSON contract, and sampling remain unchanged. Hub broadcasts coalesce without blocking; callbacks execute after storage outside the sampler lock; SSE subscribers and blocked writes clean up on cancellation.
- Tests first: Go tests failed on missing Hub/callback/handler symbols before implementation; temporary page behavior checks failed on missing EventSource before page changes. Shutdown completion was fixed during implementation without changing its assertion.
- Validation: `go fmt ./...`, `go vet ./...`, `go test ./...`, `go test -race ./...`, and staged/unstaged diff checks PASS.
- E2E: NOT_CONFIGURED as a repository command. Temporary `node /tmp/mqtt-washingmachine-t004-smoke.mjs` PASS with isolated Mosquitto and real Chrome: initial events, multiple subscribers, invalid payload suppression, disconnect/reconnect, streams surviving the actual 10-second timeout, actual 25-second keep-alives, browser refresh on MQTT, sampled readings at 360px, and prompt clean SIGTERM shutdown with active streams.
- Page behavior: temporary `node /tmp/mqtt-washingmachine-t004-page-test.mjs` PASS for burst coalescing, 60-second polling, no EventSource support, and failed-fetch recovery. Screenshot inspected: `/tmp/mqtt-washingmachine-t004-mobile.png`.
- Documentation: README covers SSE format, refresh behavior, sampling, keep-alives, proxy buffering, and manual checks; AI Workflow section is byte-for-byte unchanged.
@ -36,3 +36,21 @@ Objective: show the laundry status from the last 3 one-minute power samples of t
### Notes
### Notes
- State is in memory only. After a backend restart the page shows "Waiting for data..." until 3 samples are collected (about 3 minutes).
- State is in memory only. After a backend restart the page shows "Waiting for data..." until 3 samples are collected (about 3 minutes).
## Priority 2
Objective: the page refreshes itself whenever a new MQTT power message arrives, instead of waiting for the 60 s poll.
### Decided
- Mechanism: Server-Sent Events. New endpoint `GET /api/events` (name adjustable during planning) streams an event each time the backend receives a valid `ENERGY.Power` message (about every 10 s).
- The event is only a notification. The page reacts by re-fetching `/api/status`, so the status rule, JSON contract and 1-minute sampling stay unchanged.
- Readings still show the last 3 one-minute samples; they visibly change once per minute. No live-reading line is added.
- Page uses `EventSource` (auto-reconnect). The 60 s poll stays as a fallback if the stream is down; on reconnect the page refreshes once.
- Backend: the sampler (or MQTT handler) broadcasts to all connected SSE clients without blocking on slow clients. The HTTP `WriteTimeout` of 10 s must not kill the stream (per-route override or periodic keep-alive).
- Invalid payloads do not trigger an event.
- Docs: README documents the new endpoint and behavior.