3.4 KiB
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 theappServicesslice (registers&econet.EconetService{}) and theserviceConfig.LoadEnv()call (see Config gotcha below).pkg/econet/econet.go—EconetServiceimplementsservice.AppService. It owns the single*rheemcloud.Clientand shares it with the gRPC, MCP, and metrics sub-servers.Initconnects (fail-fast if creds missing); the returnedShutdownFuncstops metrics and closes the client.pkg/econet/econetgrpc/—EconetServicegRPC impl (ListDevices,GetDevice) +deviceToProto. Does not own a client; it's injected.pkg/econet/econetmcp/— MCP server at/api/mcp; thelist_water_heaterstool 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.
costPerKWHis US dollars per kWh (0.18 = 18¢), feedingeconet_energy_cost_dollars.
Metrics conventions
- Live state is read at scrape time inside a
RegisterCallbackoverclient.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_secondsis 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 viametric.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
*Devicehas no exported constructor, sodeviceToProtoisn'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).