# AGENTS.md — econet-exporter Exporter for Rheem EcoNet / Rheemcloud water heaters, built on the `go-app` framework (see the framework's own AGENTS.md for lifecycle details). ## Architecture - `main.go` — go-app bootstrap. The **only** app-specific lines are the `appServices` slice (registers `&econet.EconetService{}`) and the `serviceConfig.LoadEnv()` call (see Config gotcha below). - `pkg/econet/econet.go` — `EconetService` implements `service.AppService`. It **owns the single `econetclient.Client`** and shares it with the gRPC, MCP, and metrics sub-servers. `Init` fails fast if creds are missing, then refreshes device state on a ticker **in a background goroutine** (`pollLoop`, interval `pollInterval`) so a slow or unreachable cloud never blocks HTTP/gRPC server startup. The client is stateless REST, so `ShutdownFunc` is effectively a no-op (the poll goroutine stops on ctx cancel). - `pkg/econet/econetclient/` — a minimal EcoNet REST client (ClearBlade backend). `client.go` does the read REST calls (auth → `getUserDataForApp` → `dynamicAction` usage) and caches a snapshot under an `RWMutex`; `device.go` decodes the `@...` datapoints into a flat `Device` struct (ported from kevinburke/rheemcloud-go, minus MQTT). `Devices()` is empty until the first `Refresh` succeeds — consumers just see an empty list (no nil-guarding). **No MQTT**, so `WiFiSignal` and `Running`/`RunningState` stay zero. - **Writes (SetMode)**: `Client.SetMode` changes a unit's operating mode by publishing a desired-state message over ClearBlade's REST publish endpoint (`POST /message/{systemKey}/publish`, topic `user/{account_id}/device/desired`, payload `{transactionId, device_name, serial_number, "@MODE": }`). This is the HTTP equivalent of the app's MQTT publish — **no MQTT client needed**. `@MODE` is an *index* into the device's reported mode list, so `device.go` retains that list (`modeEnumText`) and maps a slug → index (`modeIndex`); supported modes are device-specific (`Device.SupportedModes`). The change is **applied asynchronously** — the new mode only shows up on a later `Refresh`, and a 200 from publish means "accepted", not "applied". - `pkg/econet/econetgrpc/` — `EconetService` gRPC impl (`ListDevices`, `GetDevice`, `SetMode`) + `deviceToProto`. Does **not** own a client; it's injected. `SetMode` maps the proto `Mode` enum → client slug; note MCP calls it in-process and so **bypass the protovalidate interceptor** (the gRPC/REST paths don't), hence `SetMode` re-checks the enum itself. - `pkg/econet/econetmcpgen/` — **active** MCP server at `/api/mcp`. Registers the tools generated by protoc-gen-go-mcp (`api/econet/v1alpha1/v1alpha1mcp/`) against our go-sdk `*mcp.Server` via the `runtime/gosdk` adapter, forwarding in-process to the gRPC server. Exposes the same package API as `econetmcp`. - `pkg/econet/econetmcp/` — the older hand-written MCP server; a single `list_water_heaters` tool delegating to the gRPC server. Kept in place as a fallback. **Switch between the two** with the aliased `econetmcp` import in `pkg/econet/econet.go` (one line — both packages export identical symbols). - `pkg/econet/econetui/` — admin UI served at **`/`** (the root; the HTTP server is not "under `/api`" — only the grpc-gateway is, via `grpcGatewayPath`). Gated by `enableWebUI` (default true). Server-rendered `html/template` + embedded assets (`embed.FS`: Pico.css classless, HTMX, per-`generic_type` SVG icons) with a dark-mode toggle (persisted in `localStorage`). Reads devices and writes mode through the in-process gRPC server, same as the MCP packages. Routes: `GET /` (page), `GET /ui/devices` (HTMX poll partial, every 30s), `POST /ui/devices/{serial}/mode` (returns a re-rendered card), `GET /ui/devices/{serial}/debug` (raw device JSON for the 🐛 modal), `GET /static/`. A **fill-level meter** visualizes hot-water availability. `iconFor` matches `@TYPE` loosely (substring, case/spacing insensitive) since real values vary (e.g. `heatpumpWaterHeaterGen5`). Template funcs (`fillClass`/`fillWidth`) take **int32** to match proto fields — `html/template` is strict about arg types. **Pending state** for a mode change is best-effort: `pending.go` holds a TTL-bounded (`pendingTTL`, 90s) desired-mode hint, shown as an "Updating…" badge and cleared once the reported mode converges; a full page load ignores it (fresh reported state), the poll honors it. **Auth is left to the edge** (oauth2-proxy) — the UI/write live at `/` and `/ui/`; keep `/health` and `/metrics` on skip-auth so probes/Prometheus keep working. - `pkg/econet/econetmetrics/` — OTEL observable gauges (read the client snapshot at scrape time; no poller of its own). ## Config gotcha (important) go-app's `MustLoadConfigInto` applies env vars **only to the embedded `AppConfig`**, not to custom `ServiceConfig` fields. So custom fields need an explicit env pass: `ServiceConfig.LoadEnv()` (in `pkg/config/custom.go`) runs `env.Parse` over the custom fields (it nils out the embedded `AppConfig` first to preserve go-app's own precedence). `main.go` calls it right after `MustLoadConfigInto`. If you add a custom config field with an `env:` tag, that's all it takes — no extra wiring. - `costPerKWH` is **US dollars per kWh** (0.18 = 18¢), feeding `econet_energy_cost_dollars`. - **The `default:` tag is NOT applied at runtime for custom `ServiceConfig` fields** — `LoadEnv` uses plain `env.Parse` (no `DefaultValueTagName`), so `default:` only feeds the JSON schema. A bool that must default to true therefore can't be a plain `bool` (zero value = false); `enableWebUI` is a `*bool` where nil ⇒ enabled (read via `WebUIEnabled()`). `disableWaterUsage` is a plain `bool` because its desired default (poll water usage) is the zero value — set it true to skip the wasted water-usage REST call on units that don't report it. - `econetTLSInsecure` (env `ECONET_TLS_INSECURE`) skips TLS certificate verification when connecting to the EcoNet cloud API. It exists because the upstream host (cloudblade) serves a DigiCert Global Root G1 chain, which is no longer trusted by default in current root stores. **Evaluate removing this later** — prefer fixing the trust chain (e.g. bundling the needed intermediate/root CA) over disabling verification outright. ## Metrics conventions - Live state and usage are both read at scrape time inside `RegisterCallback`s over `client.Devices()` (cheap, in-memory). The `pollLoop` in `econet.go` keeps the snapshot fresh over REST (`client.Refresh`), which folds daily energy/water usage totals into each `Device`. No usage cache or event draining — the callbacks just read the current snapshot. - **Attributes**: dotted/namespaced `econet.*` (semantic-convention style), low-cardinality only (serial, device_id, friendly_name, type, generic_type, mode, energy_type). **Never** put changing values (timestamps) in attributes — `econet_last_updated_seconds` is a metric *value*, and it's suppressed until the first refresh so it never emits a garbage epoch. - **Units live in metric names** (`_fahrenheit`, `_kwh`, `_gallons`, `_dollars`), NOT via `metric.WithUnit` — the OTEL→Prometheus exporter would otherwise append a second unit suffix (`..._fahrenheit_F`). ## Workflow - Regenerate proto: `make proto` (buf; `PROTO_DIRS=proto/econet/v1alpha1/*`). This also runs the `protoc-gen-go-mcp` plugin (see `buf.gen.yaml`), emitting the MCP registrars under `api/econet/v1alpha1/v1alpha1mcp/`. - Regenerate config schema after config changes: `make schema`. - Build/test: `go build ./... && go test ./...`. - `econetclient.Device` is a plain struct, so `deviceToProto` and the datapoint decoders (`mode`, `enabled`, `setpoint`, `hotWater`) are unit-testable by hand-building a `Device` or feeding raw JSON to `newDevice`. ## Live verification (needs a real Rheem account) Creds are configured via `ECONET_EMAIL` / `ECONET_PASSWORD`. Run `go run . -config config.yaml`, then check: `/metrics` (grep `econet_`), `/api/v1alpha1/devices` (REST), grpcurl on `:8081`, and MCP at `/api/mcp` (streamable HTTP; POST to `/api/mcp/` with trailing slash — bare `/api/mcp` 307-redirects). The REST gateway needs `grpcGatewayPathStrip: true` in config.yaml (otherwise the `/api` prefix isn't stripped and routes 404). `SetMode` (write) can be driven three ways once a real device is loaded: `grpcurl -plaintext -d '{"serial_number":"","mode":"MODE_HEAT_PUMP"}' :8081 econet.v1alpha1.EconetService/SetMode`, `POST /api/v1alpha1/devices//mode` with `{"mode":"MODE_HEAT_PUMP"}`, or the MCP `econet_v1alpha1_EconetService_SetMode` tool. **Unverified against a live account**: whether the user token is authorized to publish over REST (same authority as MQTT, but Rheem's topic ACL is untested) — this is the one thing that can't be confirmed without a real unit.