diff --git a/.ai/HANDOFF.md b/.ai/HANDOFF.md index 94db260..cf5e2c1 100644 --- a/.ai/HANDOFF.md +++ b/.ai/HANDOFF.md @@ -43,3 +43,20 @@ Append-only role handoff log. Each role adds one entry when its step is complete ### T-002 — commit_task (all_task) — 2026-10-12T11:17:06Z - Status: done. Commit message read from the T-002 next_task entry. + +### T-003 — next_task (dev/all_task) — 2026-10-12T11:22:18Z + +| Field | Value | +|-------|-------| +| Agent | codex | +| Summary | Added a static non-root Docker image, Compose configuration, build exclusions, and deployment/status documentation while preserving the AI Workflow section | +| Validation | fmt/vet/test PASS; Docker build/runtime and Compose overrides PASS; review PASS | +| Commit | feat(docker): package laundry status service with Docker and README | +| Next Role | dev commit | + +- PR limitation: `aide pr --dry-run` refuses main; the repository instructions require staying on the current branch. + +### T-003 — commit_task (all_task) — 2026-10-12T11:22:18Z + +- Status: done. Commit message read from the T-003 next_task entry. +- All three tasks completed and reviewed; one separate Conventional Commit per task. diff --git a/.ai/REVIEW.md b/.ai/REVIEW.md index ad65c92..a5bbff9 100644 --- a/.ai/REVIEW.md +++ b/.ai/REVIEW.md @@ -22,3 +22,16 @@ - Browser: 360px viewport, all four messages/colors, white background, large status, black readings, 47.70W/-- formatting, 60s timer, failed fetch/recovery, and no horizontal overflow PASS. Screenshot: `/tmp/mqtt-washingmachine-mobile.png`. - Implementation smoke retry: Docker reassigned a dynamically published broker port after restart; fixed the temporary harness to reserve an explicit port. All assertions retained; rerun passed. No product-code or review validation retries. - Verdict: PASS, approved for the per-task commit under `all_task`. + +## Task: T-003 + +### Review Round 1 — 2026-10-12T11:22:18Z + +- Findings: no required fixes; multi-stage static image, Compose variables, build context exclusions, and README match the plan. No application behavior changed in this task. +- Validation: `go fmt ./...`, `go vet ./...`, `go test ./...`, `docker compose config --quiet`, Docker build, and diff checks PASS. +- Packaging smoke: `node /tmp/mqtt-washingmachine-docker-smoke.mjs` PASS. Verified non-root scratch image (6,754,466 bytes), custom HTTP port/broker/topic/sample interval, actual Docker-network MQTT sampling, embedded page/API, missing samples, graceful stop with exit 0, rejection of zero duration, and all Compose environment overrides. Temporary containers/network removed. +- Documentation: original README content from `## AI Workflow` onward is byte-for-byte unchanged. Setup, rules, all six env vars, TelePeriod, API, manual validation, and limitations documented. +- E2E: NOT_CONFIGURED as a repository command; temporary packaging smoke PASS. T-002 browser/MQTT smoke also passed. +- 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. +- Verdict: PASS, approved for the per-task commit under `all_task`. diff --git a/.ai/TASKS.md b/.ai/TASKS.md index 4205985..633cdbb 100644 --- a/.ai/TASKS.md +++ b/.ai/TASKS.md @@ -22,4 +22,4 @@ Command expectations: | --- | --- | --- | --- | --- | --- | | 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 | done | 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 | 2026-10-12T11:17:06Z: fmt/vet/test/race PASS; MQTT and 360px Chrome smoke PASS | none | -| 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 | +| T-003 | Dockerfile, docker-compose example, .dockerignore, README documentation | done | Image builds (or noted if Docker unavailable); README documents rules, env vars, deployment, limitations; existing AI Workflow section untouched | 2026-10-12T11:22:18Z: fmt/vet/test PASS; Docker build/runtime PASS; README workflow preserved | none | diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a093684 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.ai +.agents +.codex +.claude +.env +.env.* +bin +build +dist +node_modules +vendor +target +*.test +*.out +*.exe +*.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e4a976a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.26.8-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY main.go ./ +COPY internal ./internal +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /laundry-status . + +FROM scratch +COPY --from=builder /laundry-status /laundry-status +USER 65532:65532 +EXPOSE 8080 +ENTRYPOINT ["/laundry-status"] diff --git a/README.md b/README.md index 6ca7fd4..2307ad8 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,149 @@ ## Overview -TODO: Describe this project. +A small Go service that shows whether the laundry is done using the Tasmota plug's +MQTT `ENERGY.Power` readings. The phone-first page has a white background, a large +status message, and the last three power readings, such as `47.70W`. Missing +readings appear as `--`. + +The backend runs continuously, even with no page open. Every 60 seconds it records +the latest valid message received since the previous tick, or a missing sample if +none arrived. It keeps only the last three samples in memory, oldest first. The +page fetches the current status on load and every 60 seconds. + +### Status rules + +Rules apply in this order: + +| Condition | Message | Color | +|-----------|---------|-------| +| Fewer than three samples recorded since startup | Waiting for data... | Grey | +| Any of the last three samples is missing | Incomplete data | Grey | +| Any sample is at least 3.00 W | Laundry not yet done | Red | +| All three samples are below 3.00 W | Laundry done | Default text color | + +Exactly **3.00 W is active**; `2.99 W` and `0 W` are idle. Malformed JSON and +payloads without numeric `ENERGY.Power` are logged and ignored; they do not replace +the last valid message or make a missing interval valid. If the HTTP request fails, +the page shows a grey "Status unavailable" message and retries at the next refresh. ## Getting Started -TODO: Add setup instructions. +### Docker Compose + +Install Docker with the Compose plugin on a server that can reach your MQTT +broker. From this repository, run: + +```bash +docker compose up --build -d +docker compose logs -f laundry-status +``` + +Open `http://:8080/` on a phone connected to your home Wi-Fi. By +default the service connects to `192.168.1.12:1883` and subscribes to +`tele/tasmota_washingmashine_C21183/SENSOR`. No broker is included in the Compose +file; it connects to your existing one. + +To override settings, export the environment variables below or place them in a +local `.env` file before running Compose. For example: + +```bash +MQTT_BROKER=tcp://192.168.1.12:1883 docker compose up --build -d +``` + +Keep credential files local. The Docker build context excludes `.env` files. +The container runs as an unprivileged user, with a static Go binary and embedded +page; no volume or database is required. Stop it with `docker compose down`. + +### Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| `MQTT_BROKER` | `tcp://192.168.1.12:1883` | Broker URL, including scheme and port | +| `MQTT_TOPIC` | `tele/tasmota_washingmashine_C21183/SENSOR` | Tasmota sensor topic | +| `MQTT_USERNAME` | Empty | Optional MQTT username | +| `MQTT_PASSWORD` | Empty | Optional MQTT password | +| `HTTP_ADDR` | `:8080` | HTTP listen address | +| `SAMPLE_INTERVAL` | `60s` | Positive Go duration between samples, for example `60s` or `1m` | + +Empty variables use the defaults. In Docker, keep the HTTP listen address on all +interfaces (for example `:8080`). If you change its port, also update the target +port in the Compose `ports` mapping. To change only the published host port, edit +the left side of `8080:8080` and leave `HTTP_ADDR` unchanged. + +Changing `SAMPLE_INTERVAL` affects backend sampling and the three-sample window; +the browser still refreshes every 60 seconds. Invalid or nonpositive intervals +stop startup with an error. The service retries an unavailable broker in the +background and subscribes again after reconnecting. HTTP remains available during +broker outages; intervals without valid messages become missing data. + +### Tasmota setup + +Configure the plug to publish sensor telemetry to the topic above, with a payload +containing a numeric power measurement, for example: + +```json +{"ENERGY":{"Power":139.60}} +``` + +The plug currently uses `TelePeriod 10` (about six messages per minute). Sampling +does not depend on that exact setting: any telemetry interval works, but an +interval longer than `SAMPLE_INTERVAL` can leave gaps and show "Incomplete data". +Only the latest message within each sampling interval is used; this is not an +average of all measurements. + +### Run and validate locally + +Install Go 1.26 or later, then run: + +```bash +go run . +``` + +Validation commands: + +```bash +go fmt ./... +go vet ./... +go test ./... +go test -race ./... +docker build -t laundry-status . +``` + +No automated E2E command is configured. For a manual MQTT smoke check, run the +service against a test broker and topic with `SAMPLE_INTERVAL=1s`. Use +`mosquitto_pub` to send a message repeatedly, at least once per second: + +```bash +mosquitto_pub -h -t -m '{"ENERGY":{"Power":2.99}}' +``` + +After three valid samples, `/api/status` should report `done`. Publish `3.00` to +get `running`, then stop publishing for a sample interval to get `incomplete`. +Reload the page to inspect each result immediately. Check the page at 360 px width +for readable text and no horizontal scrolling. + +### Status API + +`GET /api/status` returns JSON with a state, display message, and zero to three +readings ordered oldest first. Responses are not cached. For example: + +```json +{"state":"incomplete","message":"Incomplete data","readings":[{"power":47.7,"valid":true},{"power":0,"valid":false},{"power":2.99,"valid":true}]} +``` + +Use `valid` to distinguish a missing sample from a real zero-watt reading. Startup +returns `{"state":"waiting","message":"Waiting for data...","readings":[]}`. + +### Limitations + +- In-memory state only: a restart clears the window. With the default interval, + the service shows "Waiting for data..." for approximately three minutes. +- A single device and three samples only; no persistent history or notifications. +- This is an idle-power heuristic. An unused machine or a sufficiently long pause + below 3 W also results in "Laundry done"; it does not track wash-cycle phases. +- Use on a trusted home LAN only. HTTP has no TLS or authentication; keep the + published port off the public internet. ## AI Workflow diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..05d645d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + laundry-status: + build: . + restart: unless-stopped + ports: + - "8080:8080" + environment: + MQTT_BROKER: "${MQTT_BROKER:-tcp://192.168.1.12:1883}" + MQTT_TOPIC: "${MQTT_TOPIC:-tele/tasmota_washingmashine_C21183/SENSOR}" + MQTT_USERNAME: "${MQTT_USERNAME:-}" + MQTT_PASSWORD: "${MQTT_PASSWORD:-}" + HTTP_ADDR: "${HTTP_ADDR:-:8080}" + SAMPLE_INTERVAL: "${SAMPLE_INTERVAL:-60s}"