Files
econet-exporter/AGENTS.md
T
rmcguire 52ed5a7e3a
Build and Publish / container-images (push) Has been skipped
Build and Publish / check-chart (push) Successful in 15s
Build and Publish / go-binaries (push) Has been skipped
Build and Publish / helm-release (push) Has been skipped
add set mode option
2026-07-16 15:35:12 -04:00

6.8 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 read 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.
  • Writes (SetMode): Client.SetMode changes a unit's operating mode by publishing a desired-state message over ClearBlade's REST publish endpoint (POST /message/{systemKey}/publish, topic user/{account_id}/device/desired, payload {transactionId, device_name, serial_number, "@MODE": <idx>}). This is the HTTP equivalent of the app's MQTT publish — no MQTT client needed. @MODE is an index into the device's reported mode list, so device.go retains that list (modeEnumText) and maps a slug → index (modeIndex); supported modes are device-specific (Device.SupportedModes). The change is applied asynchronously — the new mode only shows up on a later Refresh, and a 200 from publish means "accepted", not "applied".
  • pkg/econet/econetgrpc/EconetService gRPC impl (ListDevices, GetDevice, SetMode) + deviceToProto. Does not own a client; it's injected. SetMode maps the proto Mode enum → client slug; note MCP calls it in-process and so bypass the protovalidate interceptor (the gRPC/REST paths don't), hence SetMode re-checks the enum itself.
  • 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).

SetMode (write) can be driven three ways once a real device is loaded: grpcurl -plaintext -d '{"serial_number":"<sn>","mode":"MODE_HEAT_PUMP"}' :8081 econet.v1alpha1.EconetService/SetMode, POST /api/v1alpha1/devices/<sn>/mode with {"mode":"MODE_HEAT_PUMP"}, or the MCP econet_v1alpha1_EconetService_SetMode tool. Unverified against a live account: whether the user token is authorized to publish over REST (same authority as MQTT, but Rheem's topic ACL is untested) — this is the one thing that can't be confirmed without a real unit.