Files
econet-exporter/AGENTS.md
T

5.3 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 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 3 REST calls (auth → getUserDataForAppdynamicAction 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.
  • pkg/econet/econetgrpc/EconetService gRPC impl (ListDevices, GetDevice) + deviceToProto. Does not own a client; it's injected.
  • 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/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.
  • 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 RegisterCallbacks 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).