implement econet exporter
Build and Publish / go-binaries (push) Has been skipped
Build and Publish / container-images (push) Has been skipped
Build and Publish / helm-release (push) Successful in 19s
Build and Publish / check-chart (push) Successful in 52s

This commit is contained in:
2026-07-04 17:22:31 -04:00
parent 8198e8cfee
commit b2ec72352a
44 changed files with 2226 additions and 1281 deletions
+66
View File
@@ -0,0 +1,66 @@
# 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`.
## 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).