Files
econet-exporter/AGENTS.md
T
2026-07-16 16:32:44 -04:00

8.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/econetui/ — admin UI served at / (the root; the HTTP server is not "under /api" — only the grpc-gateway is, via grpcGatewayPath). Gated by enableWebUI (default true). Server-rendered html/template + embedded assets (embed.FS: Pico.css classless, HTMX, per-generic_type SVG icons) with a dark-mode toggle (persisted in localStorage). Reads devices and writes mode through the in-process gRPC server, same as the MCP packages. Routes: GET / (page), GET /ui/devices (HTMX poll partial, every 30s), POST /ui/devices/{serial}/mode (returns a re-rendered card), GET /ui/devices/{serial}/debug (raw device JSON for the 🐛 modal), GET /static/. A fill-level meter visualizes hot-water availability. iconFor matches @TYPE loosely (substring, case/spacing insensitive) since real values vary (e.g. heatpumpWaterHeaterGen5). Template funcs (fillClass/fillWidth) take int32 to match proto fields — html/template is strict about arg types. Pending state for a mode change is best-effort: pending.go holds a TTL-bounded (pendingTTL, 90s) desired-mode hint, shown as an "Updating…" badge and cleared once the reported mode converges; a full page load ignores it (fresh reported state), the poll honors it. Auth is left to the edge (oauth2-proxy) — the UI/write live at / and /ui/; keep /health and /metrics on skip-auth so probes/Prometheus keep working.
  • 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.
  • The default: tag is NOT applied at runtime for custom ServiceConfig fieldsLoadEnv uses plain env.Parse (no DefaultValueTagName), so default: only feeds the JSON schema. A bool that must default to true therefore can't be a plain bool (zero value = false); enableWebUI is a *bool where nil ⇒ enabled (read via WebUIEnabled()). disableWaterUsage is a plain bool because its desired default (poll water usage) is the zero value — set it true to skip the wasted water-usage REST call on units that don't report it.
  • 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.