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
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.
<!-- Example: remove or replace this section -->
## Priority 2
Objective: optional second objective.
- Replace with optional planned outcome.
<!-- Example: remove or replace this section -->
## 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).

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