Files
econet-exporter/AGENTS.md
T
rmcguire b2ec72352a
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
implement econet exporter
2026-07-04 17:22:31 -04:00

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 the appServices slice (registers &econet.EconetService{}) and the serviceConfig.LoadEnv() call (see Config gotcha below).
  • pkg/econet/econet.goEconetService 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).