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 theappServicesslice (registers&econet.EconetService{}) and theserviceConfig.LoadEnv()call (see Config gotcha below).pkg/econet/econet.go—EconetServiceimplementsservice.AppService. It owns the singleeconetclient.Clientand shares it with the gRPC, MCP, and metrics sub-servers.Initfails fast if creds are missing, then refreshes device state on a ticker in a background goroutine (pollLoop, intervalpollInterval) so a slow or unreachable cloud never blocks HTTP/gRPC server startup. The client is stateless REST, soShutdownFuncis effectively a no-op (the poll goroutine stops on ctx cancel).pkg/econet/econetclient/— a minimal EcoNet REST client (ClearBlade backend).client.godoes the read REST calls (auth →getUserDataForApp→dynamicActionusage) and caches a snapshot under anRWMutex;device.godecodes the@...datapoints into a flatDevicestruct (ported from kevinburke/rheemcloud-go, minus MQTT).Devices()is empty until the firstRefreshsucceeds — consumers just see an empty list (no nil-guarding). No MQTT, soWiFiSignalandRunning/RunningStatestay zero.- Writes (SetMode):
Client.SetModechanges a unit's operating mode by publishing a desired-state message over ClearBlade's REST publish endpoint (POST /message/{systemKey}/publish, topicuser/{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.@MODEis an index into the device's reported mode list, sodevice.goretains 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 laterRefresh, and a 200 from publish means "accepted", not "applied". pkg/econet/econetgrpc/—EconetServicegRPC impl (ListDevices,GetDevice,SetMode) +deviceToProto. Does not own a client; it's injected.SetModemaps the protoModeenum → client slug; note MCP calls it in-process and so bypass the protovalidate interceptor (the gRPC/REST paths don't), henceSetModere-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.Servervia theruntime/gosdkadapter, forwarding in-process to the gRPC server. Exposes the same package API aseconetmcp.pkg/econet/econetmcp/— the older hand-written MCP server; a singlelist_water_heaterstool delegating to the gRPC server. Kept in place as a fallback. Switch between the two with the aliasedeconetmcpimport inpkg/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.
costPerKWHis US dollars per kWh (0.18 = 18¢), feedingeconet_energy_cost_dollars.econetTLSInsecure(envECONET_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 overclient.Devices()(cheap, in-memory). ThepollLoopineconet.gokeeps the snapshot fresh over REST (client.Refresh), which folds daily energy/water usage totals into eachDevice. 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_secondsis 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 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/*). This also runs theprotoc-gen-go-mcpplugin (seebuf.gen.yaml), emitting the MCP registrars underapi/econet/v1alpha1/v1alpha1mcp/. - Regenerate config schema after config changes:
make schema. - Build/test:
go build ./... && go test ./.... econetclient.Deviceis a plain struct, sodeviceToProtoand the datapoint decoders (mode,enabled,setpoint,hotWater) are unit-testable by hand-building aDeviceor feeding raw JSON tonewDevice.
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.