From fe3f31c9c390e6bbf0c5cef712a30b5c3e7ad5dd Mon Sep 17 00:00:00 2001 From: Domagoj Zecevic Date: Mon, 12 Oct 2026 13:04:06 +0200 Subject: [PATCH] feat(status): evaluate laundry state from the last three power samples --- .ai/HANDOFF.md | 31 +++++++++++++ .ai/PLAN.md | 80 +++++++++++++++++++++++++++++++++ .ai/REVIEW.md | 12 +++++ .ai/TASKS.md | 25 +++++++++++ ROADMAP.md | 57 +++++++++++++---------- go.mod | 3 ++ internal/sensor/payload.go | 25 +++++++++++ internal/sensor/payload_test.go | 34 ++++++++++++++ internal/status/status.go | 67 +++++++++++++++++++++++++++ internal/status/status_test.go | 70 +++++++++++++++++++++++++++++ 10 files changed, 380 insertions(+), 24 deletions(-) create mode 100644 .ai/HANDOFF.md create mode 100644 .ai/PLAN.md create mode 100644 .ai/REVIEW.md create mode 100644 .ai/TASKS.md create mode 100644 go.mod create mode 100644 internal/sensor/payload.go create mode 100644 internal/sensor/payload_test.go create mode 100644 internal/status/status.go create mode 100644 internal/status/status_test.go diff --git a/.ai/HANDOFF.md b/.ai/HANDOFF.md new file mode 100644 index 0000000..eecebf7 --- /dev/null +++ b/.ai/HANDOFF.md @@ -0,0 +1,31 @@ +# HANDOFF + +Append-only role handoff log. Each role adds one entry when its step is complete. + +--- + +### T-001, T-002, T-003 — plan — 2026-10-12T10:58:09Z + +| Field | Value | +|-------|-------| +| Agent | claude | +| Summary | Planned the laundry status service as three tasks: status logic and payload parsing, sampler/MQTT/HTTP/page, then Docker and README | +| Files Changed | .ai/PLAN.md, .ai/TASKS.md, .ai/HANDOFF.md, ROADMAP.md | +| Next Role | implement | + +--- + +### T-001 — next_task (dev/all_task) — 2026-10-12T11:03:20Z + +| Field | Value | +|-------|-------| +| Agent | codex | +| Summary | Added a three-sample ring, laundry state evaluation, and numeric Tasmota power parsing with tests first and package documentation | +| Validation | fmt/vet/test PASS; E2E NOT_CONFIGURED; review PASS | +| Commit | feat(status): evaluate laundry state from the last three power samples | +| Next Role | dev commit | + +### T-001 — commit_task (all_task) — 2026-10-12T11:03:56Z + +- Status: done. Commit message read from the T-001 next_task entry. +- Included the existing roadmap and planning artifacts that define this cycle. diff --git a/.ai/PLAN.md b/.ai/PLAN.md new file mode 100644 index 0000000..e3ccef3 --- /dev/null +++ b/.ai/PLAN.md @@ -0,0 +1,80 @@ +# Plan + +Status: **ready_for_implement** + +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`. + +## Scope +- Go module `mqtt_washingmachine` (Go 1.26), standard library plus `github.com/eclipse/paho.mqtt.golang` for MQTT. +- Layout: + - `main.go`: config from env, wiring, graceful shutdown. + - `internal/status/`: pure logic (sample window, status evaluation). + - `internal/sensor/`: Tasmota payload parsing, MQTT subscription, per-minute sampler. + - `internal/web/`: HTTP handlers and embedded static page (`go:embed`). +- Docker packaging and documentation. +- Out of scope: persistence, TLS/auth, history beyond 3 samples, multiple devices. + +## Design decisions (from ROADMAP.md) +- 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. +- Window: ring of the last 3 samples (oldest first). Each sample is `{Power float64, Valid bool}`. +- Status evaluation (first match wins): + 1. fewer than 3 samples recorded since start -> `waiting`, "Waiting for data...", grey + 2. any of the last 3 samples missing -> `incomplete`, "Incomplete data", grey + 3. any sample `>= 3.0` -> `running`, "Laundry not yet done", red + 4. otherwise (all 3 `< 3.0`) -> `done`, "Laundry done", default text color +- JSON contract for `GET /api/status`: + ```json + {"state":"running","message":"Laundry not yet done","readings":[{"power":47.7,"valid":true},{"power":0,"valid":false}]} + ``` + `readings` has 0–3 entries, oldest first. The page renders the display text, color class, and `47.70W` / `--` formatting. +- Config env vars: `MQTT_BROKER` (default `tcp://192.168.1.12:1883`), `MQTT_TOPIC` (default `tele/tasmota_washingmashine_C21183/SENSOR`), `MQTT_USERNAME`, `MQTT_PASSWORD` (optional), `HTTP_ADDR` (default `:8080`), `SAMPLE_INTERVAL` (Go duration, default `60s`). +- MQTT client uses auto-reconnect and re-subscribes in the `OnConnect` handler. + +## 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 `--`. +- The page fetches `/api/status` on load and every 60 s; no horizontal scroll at 360 px width. +- Status states and colors match the table in `ROADMAP.md` exactly, including `>= 3.00 W` = not done. +- The backend collects samples continuously with no page open; a page load immediately reflects the current state. +- A minute with no MQTT message produces a missing sample and `incomplete` state. +- Malformed payloads or payloads without `ENERGY.Power` are ignored (logged), without crashing or changing the latest value. +- `docker build` succeeds and the container runs with env-var configuration; README documents setup. +- `go fmt ./...`, `go vet ./...`, `go test ./...` all pass. + +## Tasks + +### T-001 — Status logic and Tasmota payload parsing +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, <3 samples -> 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. +- `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/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/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. +- `main.go`: env config with defaults, wiring, signal-based shutdown. +- `go.mod`, `go.sum`: add `github.com/eclipse/paho.mqtt.golang`. +- Documentation: code comments; README deferred to T-003. + +### T-003 — Docker packaging and README +- `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` diff --git a/.ai/REVIEW.md b/.ai/REVIEW.md new file mode 100644 index 0000000..cfd0e92 --- /dev/null +++ b/.ai/REVIEW.md @@ -0,0 +1,12 @@ +# Review Log + +## Task: T-001 + +### Review Round 1 — 2026-10-12T11:03:20Z + +- Findings: no required fixes; implementation and package comments match the plan. +- Tests first: targeted tests initially failed on missing symbols, then passed after implementation. +- Validation: `go fmt ./...`, `go vet ./...`, `go test ./...`, and `git diff --check` PASS. +- 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. +- Verdict: PASS, approved for the per-task commit under `all_task`. diff --git a/.ai/TASKS.md b/.ai/TASKS.md new file mode 100644 index 0000000..49d6cc1 --- /dev/null +++ b/.ai/TASKS.md @@ -0,0 +1,25 @@ +# TASKS + +Use this board to coordinate handoff between planner, implementer, and reviewer. + +Status values: +- `in_planning` +- `ready_for_implement` +- `in_implementation` +- `ready_for_review` +- `in_review` +- `ready_to_commit` +- `changes_requested` +- `done` + +Command expectations: +- planner moves tasks into `in_planning` and `ready_for_implement` +- implementer moves tasks into `in_implementation`, `ready_for_review`, and `done`, and resumes work from `changes_requested` and `ready_to_commit` +- reviewer moves tasks into `in_review`, `ready_to_commit`, or `changes_requested` +- `status_cycle` should report deterministic task status, current owner role, and next recommended action based on this board + +| 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-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-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 | diff --git a/ROADMAP.md b/ROADMAP.md index 9ba667b..96961d7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,29 +1,38 @@ # ROADMAP -Goal: define and deliver the scope for this cycle. - -Delete any unused example sections below. Only the Goal and one concrete priority are required. +Goal: a small, phone-first webpage that tells whether the laundry is done, based on the washing machine's power consumption. ## Priority 1 -Objective: replace with objective. - -- Replace with planned outcome. - -## Examples - -These example sections are optional illustrations, not required structure. - - -## Priority 2 - -Objective: optional second objective. - -- Replace with optional planned outcome. - - -## Priority 3 - -Objective: optional third objective. - -- Replace with optional planned outcome. +Objective: show the laundry status from the last 3 one-minute power samples of the Tasmota plug. + +### Decided + +- Data source: MQTT topic `tele/tasmota_washingmashine_C21183/SENSOR` (Tasmota JSON, field `ENERGY.Power` in watts). +- Broker: `192.168.1.12:1883`, no credentials assumed. Address, port, topic and credentials (optional) are configurable via env vars. +- Tasmota `TelePeriod` is 10 s, so the plug publishes about 6 messages per minute. +- Architecture: small Go backend, packaged in Docker (Dockerfile plus docker-compose example), deployed by the user on their own server. + - Subscribes to the broker over TCP and runs continuously, whether or not any page is open. + - Sampling: it keeps the most recent `ENERGY.Power` message and records one sample per minute. A minute with no message received is recorded as a missing sample. + - Keeps the last 3 samples in memory. + - Serves the webpage and a JSON status endpoint (e.g. `/api/status`). Status is computed on the backend, so a page load shows the correct state at once. + - HTTP port is configurable, default 8080. + - The page re-fetches the status every 60 s. +- Status rule (threshold 3 W, strictly below means idle): + +| Condition | Message | Color | +|---|---|---| +| fewer than 3 samples since backend start | "Waiting for data..." | grey | +| any of the last 3 samples is missing | "Incomplete data" | grey | +| all 3 samples under 3.00 W | "Laundry done" | default text color | +| otherwise (any sample 3.00 W or more) | "Laundry not yet done" | red | + +- Page design: + - White background, status text very large (the main element), readable on phones (responsive, viewport meta, no horizontal scroll). + - Below the status, the last 3 power values in normal-size black text with a `W` suffix and 2 decimals (e.g. `47.70W`). A missing sample is shown as `--`. +- Access: home Wi-Fi only. No TLS or authentication in scope. +- Language: English for UI text, code comments, logs, README. + +### Notes + +- State is in memory only. After a backend restart the page shows "Waiting for data..." until 3 samples are collected (about 3 minutes). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..52f5177 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module mqtt_washingmachine + +go 1.26.0 diff --git a/internal/sensor/payload.go b/internal/sensor/payload.go new file mode 100644 index 0000000..569035f --- /dev/null +++ b/internal/sensor/payload.go @@ -0,0 +1,25 @@ +// Package sensor reads Tasmota ENERGY.Power measurements in watts. +package sensor + +import ( + "encoding/json" + "errors" + "fmt" +) + +// ParsePower accepts numeric ENERGY.Power, including zero. Missing, null, or +// malformed measurements return an error so callers can preserve the last value. +func ParsePower(payload []byte) (float64, error) { + var message struct { + Energy *struct { + Power *float64 `json:"Power"` + } `json:"ENERGY"` + } + if err := json.Unmarshal(payload, &message); err != nil { + return 0, fmt.Errorf("decode sensor payload: %w", err) + } + if message.Energy == nil || message.Energy.Power == nil { + return 0, errors.New("sensor payload has no numeric ENERGY.Power") + } + return *message.Energy.Power, nil +} diff --git a/internal/sensor/payload_test.go b/internal/sensor/payload_test.go new file mode 100644 index 0000000..e2d4fa6 --- /dev/null +++ b/internal/sensor/payload_test.go @@ -0,0 +1,34 @@ +package sensor + +import "testing" + +func TestParsePower(t *testing.T) { + for _, tt := range []struct { + name string + payload string + power float64 + wantErr bool + }{ + {"tasmota", `{"Time":"2026-10-12T12:00:00","ENERGY":{"Total":12.5,"Voltage":230,"Power":139.60}}`, 139.6, false}, + {"zero", `{"ENERGY":{"Power":0}}`, 0, false}, + {"threshold", `{"ENERGY":{"Power":3.00}}`, 3, false}, + {"missing energy", `{}`, 0, true}, + {"missing power", `{"ENERGY":{"Voltage":230}}`, 0, true}, + {"null energy", `{"ENERGY":null}`, 0, true}, + {"null power", `{"ENERGY":{"Power":null}}`, 0, true}, + {"string power", `{"ENERGY":{"Power":"139.60"}}`, 0, true}, + {"overflow", `{"ENERGY":{"Power":1e1000}}`, 0, true}, + {"invalid json", `{"ENERGY":`, 0, true}, + {"trailing json", `{"ENERGY":{"Power":1}} {}`, 0, true}, + } { + t.Run(tt.name, func(t *testing.T) { + got, err := ParsePower([]byte(tt.payload)) + if (err != nil) != tt.wantErr { + t.Fatalf("ParsePower() error = %v; want error %v", err, tt.wantErr) + } + if !tt.wantErr && got != tt.power { + t.Errorf("ParsePower() = %v; want %v", got, tt.power) + } + }) + } +} diff --git a/internal/status/status.go b/internal/status/status.go new file mode 100644 index 0000000..7712b23 --- /dev/null +++ b/internal/status/status.go @@ -0,0 +1,67 @@ +// Package status evaluates laundry state from the latest three power samples. +// Power below 3 W is idle; missing samples take precedence over active readings. +package status + +const windowSize = 3 + +// Sample represents one sampling interval. Invalid samples have no power reading. +type Sample struct { + Power float64 `json:"power"` + Valid bool `json:"valid"` +} + +// Result is the status API response, with readings ordered oldest first. +type Result struct { + State string `json:"state"` + Message string `json:"message"` + Readings []Sample `json:"readings"` +} + +// Window holds the last three samples. Callers synchronize concurrent access. +// The zero value is an empty window. +type Window struct { + samples [windowSize]Sample + next int + count int +} + +// Add records an interval, including intervals without a valid reading. +func (w *Window) Add(sample Sample) { + w.samples[w.next] = sample + w.next = (w.next + 1) % windowSize + if w.count < windowSize { + w.count++ + } +} + +// Readings returns an independent snapshot, oldest first, including missing data. +func (w *Window) Readings() []Sample { + readings := make([]Sample, w.count) + for i := range w.count { + readings[i] = w.samples[(w.next-w.count+i+windowSize)%windowSize] + } + return readings +} + +// Evaluate applies waiting, incomplete, running, then done precedence to a +// window's readings. An empty window serializes as [] rather than null. +func Evaluate(readings []Sample) Result { + result := Result{State: "waiting", Message: "Waiting for data...", Readings: append([]Sample{}, readings...)} + if len(readings) < windowSize { + return result + } + for _, sample := range readings { + if !sample.Valid { + result.State, result.Message = "incomplete", "Incomplete data" + return result + } + } + for _, sample := range readings { + if sample.Power >= 3 { + result.State, result.Message = "running", "Laundry not yet done" + return result + } + } + result.State, result.Message = "done", "Laundry done" + return result +} diff --git a/internal/status/status_test.go b/internal/status/status_test.go new file mode 100644 index 0000000..ed4ba48 --- /dev/null +++ b/internal/status/status_test.go @@ -0,0 +1,70 @@ +package status + +import ( + "reflect" + "testing" +) + +func TestEvaluate(t *testing.T) { + valid := func(power float64) Sample { return Sample{Power: power, Valid: true} } + for _, tt := range []struct { + name string + samples []Sample + state string + message string + }{ + {"no samples", nil, "waiting", "Waiting for data..."}, + {"one sample", []Sample{valid(10)}, "waiting", "Waiting for data..."}, + {"two samples with gap", []Sample{valid(10), {}}, "waiting", "Waiting for data..."}, + {"all zero", []Sample{valid(0), valid(0), valid(0)}, "done", "Laundry done"}, + {"below threshold", []Sample{valid(2.99), valid(2.99), valid(2.99)}, "done", "Laundry done"}, + {"at threshold", []Sample{valid(0), valid(3), valid(0)}, "running", "Laundry not yet done"}, + {"mixed", []Sample{valid(47.7), valid(0), valid(2.99)}, "running", "Laundry not yet done"}, + {"gap overrides active", []Sample{valid(47.7), {}, valid(0)}, "incomplete", "Incomplete data"}, + {"all missing", []Sample{{}, {}, {}}, "incomplete", "Incomplete data"}, + } { + t.Run(tt.name, func(t *testing.T) { + got := Evaluate(tt.samples) + if got.State != tt.state || got.Message != tt.message { + t.Fatalf("Evaluate() = %q, %q; want %q, %q", got.State, got.Message, tt.state, tt.message) + } + if len(got.Readings) != len(tt.samples) || got.Readings == nil { + t.Fatalf("readings = %#v; want non-nil slice of length %d", got.Readings, len(tt.samples)) + } + for i, sample := range tt.samples { + if got.Readings[i] != sample { + t.Errorf("readings[%d] = %+v; want %+v", i, got.Readings[i], sample) + } + } + }) + } +} + +func TestWindowKeepsLastThreeOldestFirst(t *testing.T) { + var window Window + if got := window.Readings(); len(got) != 0 { + t.Fatalf("new window = %v", got) + } + for i := range 7 { + window.Add(Sample{Power: float64(i), Valid: true}) + } + want := []Sample{{Power: 4, Valid: true}, {Power: 5, Valid: true}, {Power: 6, Valid: true}} + if got := window.Readings(); !reflect.DeepEqual(got, want) { + t.Fatalf("Readings() = %v; want %v", got, want) + } + snapshot := window.Readings() + snapshot[0].Power = 999 + if got := window.Readings(); !reflect.DeepEqual(got, want) { + t.Fatal("mutating a snapshot changed the window") + } + for range 3 { + window.Add(Sample{Valid: true}) + } + if got := Evaluate(window.Readings()); got.State != "done" { + t.Fatalf("old active samples did not age out: %+v", got) + } + window.Add(Sample{}) + if got := Evaluate(window.Readings()); got.State != "incomplete" { + t.Fatalf("missing sample was not recorded: %+v", got) + } +}