Browse Source

feat(status): evaluate laundry state from the last three power samples

main
Domagoj Zecevic 3 weeks ago
parent
commit
fe3f31c9c3
  1. 31
      .ai/HANDOFF.md
  2. 80
      .ai/PLAN.md
  3. 12
      .ai/REVIEW.md
  4. 25
      .ai/TASKS.md
  5. 57
      ROADMAP.md
  6. 3
      go.mod
  7. 25
      internal/sensor/payload.go
  8. 34
      internal/sensor/payload_test.go
  9. 67
      internal/status/status.go
  10. 70
      internal/status/status_test.go

31
.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.

80
.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`

12
.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`.

25
.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 |

57
ROADMAP.md

@ -1,29 +1,38 @@
# ROADMAP # ROADMAP
Goal: define and deliver the scope for this cycle. Goal: a small, phone-first webpage that tells whether the laundry is done, based on the washing machine's power consumption.
Delete any unused example sections below. Only the Goal and one concrete priority are required.
## Priority 1 ## Priority 1
Objective: replace with objective. Objective: show the laundry status from the last 3 one-minute power samples of the Tasmota plug.
- Replace with planned outcome. ### Decided
## Examples - 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.
These example sections are optional illustrations, not required structure. - 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.
<!-- Example: remove or replace this section --> - Subscribes to the broker over TCP and runs continuously, whether or not any page is open.
## Priority 2 - 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.
Objective: optional second objective. - 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.
- Replace with optional planned outcome. - The page re-fetches the status every 60 s.
- Status rule (threshold 3 W, strictly below means idle):
<!-- Example: remove or replace this section -->
## Priority 3 | Condition | Message | Color |
|---|---|---|
Objective: optional third objective. | fewer than 3 samples since backend start | "Waiting for data..." | grey |
| any of the last 3 samples is missing | "Incomplete data" | grey |
- Replace with optional planned outcome. | 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).

3
go.mod

@ -0,0 +1,3 @@
module mqtt_washingmachine
go 1.26.0

25
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
}

34
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)
}
})
}
}

67
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
}

70
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)
}
}
Loading…
Cancel
Save