# 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 `*rheemcloud.Client`** and shares it with the gRPC, MCP, and metrics sub-servers. `Init` connects (fail-fast if creds missing); the returned `ShutdownFunc` stops metrics and closes the client. - `pkg/econet/econetgrpc/` — `EconetService` gRPC impl (`ListDevices`, `GetDevice`) + `deviceToProto`. Does **not** own a client; it's injected. - `pkg/econet/econetmcp/` — MCP server at `/api/mcp`; the `list_water_heaters` tool delegates to the gRPC server. - `pkg/econet/econetmetrics/` — OTEL observable gauges + a usage poller. ## 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`. - `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 is read at scrape time inside a `RegisterCallback` over `client.Devices()` (cheap, in-memory, always current — the rheemcloud client keeps state fresh over MQTT). Usage (`EnergyUsage`/`WaterUsage`) is REST and slow, so a background poller (`usageInterval`) caches it; the usage callback reads the cache. - **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 MQTT update 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/*`). - Regenerate config schema after config changes: `make schema`. - Build/test: `go build ./... && go test ./...`. - The rheemcloud `*Device` has no exported constructor, so `deviceToProto` isn't unit-testable in isolation; test the pure helpers (`summarizeDevices`, `b2i`) and exercise the rest with a live run. ## 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).