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

114 lines
6.8 KiB
Markdown

# 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.go``EconetService` 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 → `getUserDataForApp`
`dynamicAction` 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 `RegisterCallback`s
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.