26 Commits

Author SHA1 Message Date
rmcguire 8effff311b update CHANGELOG
Build and Publish / check-chart (push) Successful in 15s
Build and Publish / container-images (push) Successful in 3m1s
Build and Publish / go-binaries (push) Successful in 3m59s
Build and Publish / helm-release (push) Has been skipped
2026-07-16 15:39:06 -04:00
rmcguire 52ed5a7e3a add set mode option
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
2026-07-16 15:35:12 -04:00
rmcguire 8e0e59db16 remove manual econet mcp 2026-07-16 15:03:21 -04:00
rmcguire 22137e7ebe move to generated mcp for econet 2026-07-16 15:02:17 -04:00
rmcguire 18e9cbecea improve dashboard
Build and Publish / go-binaries (push) Has been skipped
Build and Publish / container-images (push) Has been skipped
Build and Publish / check-chart (push) Successful in 10s
Build and Publish / helm-release (push) Successful in 16s
2026-07-06 11:46:32 -04:00
rmcguire 49e6d11180 improve dashboard
Build and Publish / container-images (push) Has been skipped
Build and Publish / helm-release (push) Successful in 16s
Build and Publish / go-binaries (push) Has been skipped
Build and Publish / check-chart (push) Successful in 9s
2026-07-06 11:35:25 -04:00
rmcguire 5867750437 Improve dashboard
Build and Publish / go-binaries (push) Has been skipped
Build and Publish / container-images (push) Has been skipped
Build and Publish / check-chart (push) Successful in 9s
Build and Publish / helm-release (push) Successful in 16s
2026-07-06 11:23:51 -04:00
rmcguire f89e2be74b improve dashboard
Build and Publish / go-binaries (push) Has been skipped
Build and Publish / container-images (push) Has been skipped
Build and Publish / helm-release (push) Successful in 15s
Build and Publish / check-chart (push) Successful in 12s
2026-07-06 11:01:23 -04:00
rmcguire 44d43b0ac8 improve dashboard
Build and Publish / go-binaries (push) Has been skipped
Build and Publish / check-chart (push) Successful in 14s
Build and Publish / container-images (push) Has been skipped
Build and Publish / helm-release (push) Successful in 17s
2026-07-06 10:42:36 -04:00
rmcguire 4645c5d5a1 improve dashboard
Build and Publish / go-binaries (push) Has been skipped
Build and Publish / container-images (push) Has been skipped
Build and Publish / check-chart (push) Successful in 52s
Build and Publish / helm-release (push) Successful in 17s
2026-07-05 21:15:33 -04:00
rmcguire 211d833489 fix dashboard
Build and Publish / go-binaries (push) Has been skipped
Build and Publish / helm-release (push) Successful in 18s
Build and Publish / container-images (push) Has been skipped
Build and Publish / check-chart (push) Successful in 9s
2026-07-05 21:02:45 -04:00
rmcguire b84cef01f4 fix dashboard 2026-07-05 20:53:39 -04:00
rmcguire 307d31491e fix dashboard
Build and Publish / container-images (push) Has been skipped
Build and Publish / check-chart (push) Successful in 12s
Build and Publish / go-binaries (push) Has been skipped
Build and Publish / helm-release (push) Successful in 18s
2026-07-05 20:44:45 -04:00
rmcguire 39424ce663 upgrade hull version to support helm4
Build and Publish / go-binaries (push) Has been skipped
Build and Publish / container-images (push) Has been skipped
Build and Publish / check-chart (push) Successful in 31s
Build and Publish / helm-release (push) Successful in 17s
2026-07-05 20:33:42 -04:00
rmcguire e9cdc1435e update ci deps
Build and Publish / check-chart (push) Successful in 32s
Build and Publish / helm-release (push) Failing after 32s
Build and Publish / go-binaries (push) Has been skipped
Build and Publish / container-images (push) Has been skipped
2026-07-05 17:37:40 -04:00
rmcguire a2faf4d412 update ci deps 2026-07-05 17:37:29 -04:00
rmcguire b8e2c2c064 update checkout action
Build and Publish / go-binaries (push) Has been skipped
Build and Publish / container-images (push) Has been skipped
Build and Publish / check-chart (push) Successful in 12s
Build and Publish / helm-release (push) Has been skipped
2026-07-05 17:35:13 -04:00
rmcguire defd5b861c add grafana dashboard
Build and Publish / container-images (push) Has been skipped
Build and Publish / go-binaries (push) Has been skipped
Build and Publish / check-chart (push) Failing after 32s
Build and Publish / helm-release (push) Has been skipped
2026-07-05 17:30:30 -04:00
rmcguire 38078a330e update changelog, bump chart
Build and Publish / go-binaries (push) Failing after 58s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 10s
Build and Publish / container-images (push) Successful in 2m14s
2026-07-05 17:01:01 -04:00
rmcguire 047f7c233d remove rheemcloud pkg, implement against rest api only
Build and Publish / helm-release (push) Has been skipped
Build and Publish / go-binaries (push) Has been skipped
Build and Publish / container-images (push) Has been skipped
Build and Publish / check-chart (push) Successful in 12s
2026-07-05 16:59:53 -04:00
rmcguire dcdf5e4230 bump chart
Build and Publish / go-binaries (push) Successful in 1m41s
Build and Publish / check-chart (push) Successful in 10s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / container-images (push) Successful in 5m19s
2026-07-05 14:56:30 -04:00
rmcguire 1b42d26ffc add changelog 2026-07-05 14:56:10 -04:00
rmcguire a8e0a6ef1e support tls insecure due to cloudblade old TLS cert 2026-07-05 14:53:10 -04:00
rmcguire 059b89b5cd add TODO
Build and Publish / helm-release (push) Has been skipped
Build and Publish / container-images (push) Has been skipped
Build and Publish / go-binaries (push) Has been skipped
Build and Publish / check-chart (push) Successful in 10s
2026-07-04 17:47:54 -04:00
rmcguire 87e53bd620 bump chart versions
Build and Publish / container-images (push) Successful in 2m10s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / check-chart (push) Successful in 16s
Build and Publish / go-binaries (push) Successful in 5m29s
2026-07-04 17:42:50 -04:00
rmcguire c481d3f032 add ca-certs to image
Build and Publish / check-chart (push) Successful in 14s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / go-binaries (push) Successful in 2m35s
Build and Publish / container-images (push) Successful in 3m1s
2026-07-04 17:42:03 -04:00
33 changed files with 4032 additions and 406 deletions
+5 -5
View File
@@ -25,12 +25,12 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v') # Only run on tag push if: startsWith(github.ref, 'refs/tags/v') # Only run on tag push
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Set up Go Environment - name: Set up Go Environment
uses: actions/setup-go@v4 uses: actions/setup-go@v4
with: with:
go-version: '1.24' go-version: '1.26'
- name: Build Binary - name: Build Binary
run: make build run: make build
@@ -72,7 +72,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/v') # Only run on tag push if: startsWith(github.ref, 'refs/tags/v') # Only run on tag push
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -102,7 +102,7 @@ jobs:
outputs: outputs:
chart-updated: ${{ steps.filter.outputs.chart }} chart-updated: ${{ steps.filter.outputs.chart }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- name: Check Chart Changed - name: Check Chart Changed
uses: dorny/paths-filter@v3 uses: dorny/paths-filter@v3
id: filter id: filter
@@ -118,7 +118,7 @@ jobs:
if: ${{ needs.check-chart.outputs.chart-updated == 'true' }} if: ${{ needs.check-chart.outputs.chart-updated == 'true' }}
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Install Helm - name: Install Helm
env: env:
+63 -16
View File
@@ -9,14 +9,44 @@ Exporter for Rheem EcoNet / Rheemcloud water heaters, built on the
`appServices` slice (registers `&econet.EconetService{}`) and the `appServices` slice (registers `&econet.EconetService{}`) and the
`serviceConfig.LoadEnv()` call (see Config gotcha below). `serviceConfig.LoadEnv()` call (see Config gotcha below).
- `pkg/econet/econet.go``EconetService` implements `service.AppService`. - `pkg/econet/econet.go``EconetService` implements `service.AppService`.
It **owns the single `*rheemcloud.Client`** and shares it with the gRPC, It **owns the single `econetclient.Client`** and shares it with the gRPC,
MCP, and metrics sub-servers. `Init` connects (fail-fast if creds missing); MCP, and metrics sub-servers. `Init` fails fast if creds are missing, then
the returned `ShutdownFunc` stops metrics and closes the client. 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`, - `pkg/econet/econetgrpc/``EconetService` gRPC impl (`ListDevices`,
`GetDevice`) + `deviceToProto`. Does **not** own a client; it's injected. `GetDevice`, `SetMode`) + `deviceToProto`. Does **not** own a client; it's
- `pkg/econet/econetmcp/` — MCP server at `/api/mcp`; the `list_water_heaters` injected. `SetMode` maps the proto `Mode` enum → client slug; note MCP calls
tool delegates to the gRPC server. it in-process and so **bypass the protovalidate interceptor** (the gRPC/REST
- `pkg/econet/econetmetrics/` — OTEL observable gauges + a usage poller. 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) ## Config gotcha (important)
@@ -30,19 +60,26 @@ that's all it takes — no extra wiring.
- `costPerKWH` is **US dollars per kWh** (0.18 = 18¢), feeding - `costPerKWH` is **US dollars per kWh** (0.18 = 18¢), feeding
`econet_energy_cost_dollars`. `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 ## Metrics conventions
- Live state is read at scrape time inside a `RegisterCallback` over - Live state and usage are both read at scrape time inside `RegisterCallback`s
`client.Devices()` (cheap, in-memory, always current — the rheemcloud client over `client.Devices()` (cheap, in-memory). The `pollLoop` in `econet.go`
keeps state fresh over MQTT). Usage (`EnergyUsage`/`WaterUsage`) is REST and keeps the snapshot fresh over REST (`client.Refresh`), which folds daily
slow, so a background poller (`usageInterval`) caches it; the usage callback energy/water usage totals into each `Device`. No usage cache or event
reads the cache. draining — the callbacks just read the current snapshot.
- **Attributes**: dotted/namespaced `econet.*` (semantic-convention style), - **Attributes**: dotted/namespaced `econet.*` (semantic-convention style),
low-cardinality only (serial, device_id, friendly_name, type, generic_type, low-cardinality only (serial, device_id, friendly_name, type, generic_type,
mode, energy_type). **Never** put changing values (timestamps) in attributes mode, energy_type). **Never** put changing values (timestamps) in attributes
`econet_last_updated_seconds` is a metric *value*, and it's suppressed `econet_last_updated_seconds` is a metric *value*, and it's suppressed
until the first MQTT update so it never emits a garbage epoch. until the first refresh so it never emits a garbage epoch.
- **Units live in metric names** (`_fahrenheit`, `_kwh`, `_gallons`, - **Units live in metric names** (`_fahrenheit`, `_kwh`, `_gallons`,
`_dollars`), NOT via `metric.WithUnit` — the OTEL→Prometheus exporter would `_dollars`), NOT via `metric.WithUnit` — the OTEL→Prometheus exporter would
otherwise append a second unit suffix (`..._fahrenheit_F`). otherwise append a second unit suffix (`..._fahrenheit_F`).
@@ -50,11 +87,13 @@ that's all it takes — no extra wiring.
## Workflow ## Workflow
- Regenerate proto: `make proto` (buf; `PROTO_DIRS=proto/econet/v1alpha1/*`). - 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`. - Regenerate config schema after config changes: `make schema`.
- Build/test: `go build ./... && go test ./...`. - Build/test: `go build ./... && go test ./...`.
- The rheemcloud `*Device` has no exported constructor, so `deviceToProto` - `econetclient.Device` is a plain struct, so `deviceToProto` and the datapoint
isn't unit-testable in isolation; test the pure helpers (`summarizeDevices`, decoders (`mode`, `enabled`, `setpoint`, `hotWater`) are unit-testable by
`b2i`) and exercise the rest with a live run. hand-building a `Device` or feeding raw JSON to `newDevice`.
## Live verification (needs a real Rheem account) ## Live verification (needs a real Rheem account)
@@ -64,3 +103,11 @@ Creds are configured via `ECONET_EMAIL` / `ECONET_PASSWORD`. Run
(streamable HTTP; POST to `/api/mcp/` with trailing slash — bare `/api/mcp` (streamable HTTP; POST to `/api/mcp/` with trailing slash — bare `/api/mcp`
307-redirects). The REST gateway needs `grpcGatewayPathStrip: true` in 307-redirects). The REST gateway needs `grpcGatewayPathStrip: true` in
config.yaml (otherwise the `/api` prefix isn't stripped and routes 404). 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.
+102 -15
View File
@@ -1,18 +1,105 @@
# v0.7.0 # Changelog
* feat: Add HTTP log exclusion regex paths to configuration schema.
* chore: Update Go version to 1.25.
* chore: Update module dependencies.
# v0.6.0 All notable changes to econet-exporter are documented here. The format is
* feat: Introduce Model Context Protocol (MCP) server with a demo random fact tool. based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
* feat: Add MCP server configuration file (`contrib/mcpinspector.json`). project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
* deps: Update Go module dependencies, including `bufbuild/protovalidate`, `go-app`, `grpc-gateway`, `googleapis/api`, `grpc`, `protobuf`, `golang.org/x/exp`, and `googleapis/rpc`.
* deps: Add new Go module dependencies: `modelcontextprotocol/go-sdk` and `k8s.io/utils`.
# v0.5.0 ## [Unreleased]
* Added OpenTelemetry tracing for application startup. ## [v0.4.0] - 2026-07-16
* Updated Go module dependencies, including `protovalidate`, `go-app`, `grpc`, and OpenTelemetry related packages.
* Enhanced `Makefile` `rename` target to update the application name constant in `main.go`. ### Added
* Configured `air` live-reloading to exclude the `proto` directory.
* Refactored application initialization logic for improved modularity and OpenTelemetry integration. * **Mode control (first mutating RPC).** New `SetMode` RPC on `EconetService`,
exposed over gRPC, the REST gateway (`POST /v1alpha1/devices/{serial}/mode`),
and as an MCP tool. Writes go out over ClearBlade's REST publish endpoint
(`POST /message/{systemKey}/publish`, topic `user/{account_id}/device/desired`,
payload `{"@MODE": <idx>, ...}`) — the HTTP equivalent of the app's MQTT
publish, so **no MQTT client is required**. The mode is applied
asynchronously by the Rheem cloud and only reflected on a later poll.
* `Mode` proto enum (`off`, `electric`, `energy-saving`, `heat-pump`,
`high-demand`, `gas`, `performance`, `vacation`) with protovalidate rejecting
the unspecified value. The enum flows into the MCP tool's input schema, so
clients see the valid modes. Supported modes are validated per-device against
the unit's reported mode list (`@MODE` is published as an index into it).
* Generated MCP server (`econetmcpgen`) built from the proto via
`protoc-gen-go-mcp`, now mounted at `/api/mcp`, forwarding in-process to the
gRPC server through the go-sdk adapter. The hand-written `econetmcp` package
is retained as a one-line-switchable fallback in `pkg/econet/econet.go`.
* Optional Grafana dashboard shipped as a ConfigMap in the Helm chart. Enable
with `hull.config.settings.grafanaDashboard.enabled=true`; the ConfigMap is
labeled `grafana_dashboard: "1"` for the Grafana sidecar to auto-import. The
dashboard uses the default Prometheus datasource (no hard-coded UID) and has a
multi-select `Device` filter driven by `econet_friendly_name`. Covers connect/
heating/mode/setpoint/hot-water/alerts, setpoint range, hot-water availability,
energy + cost + water usage, a heating-state timeline, WiFi signal, and a
device-info table. Sidecar label/value and Grafana folder are configurable.
### Changed
* MCP tool surface changed with the switch to generated tools: `/api/mcp` now
serves `econet_v1alpha1_EconetService_ListDevices`, `_GetDevice`, and
`_SetMode` (structured JSON output) instead of the single `list_water_heaters`
summary tool. Switch back via the aliased import in `pkg/econet/econet.go`.
## [v0.3.0] - 2026-07-05
### Changed
* Replaced the third-party `kevinburke/rheemcloud-go` dependency and its MQTT
coupling with an in-repo `econetclient` REST poller that talks directly to
the Rheem/EcoNet cloud REST API. Device state (including `@RUNNING`, `@MODE`,
and `@STATUS`) is sourced from the REST `getUserDataForApp` payload, so no
MQTT connection is required.
### Removed
* `rheemcloud` package and its `go.mod`/`go.sum` entries.
## [v0.2.0] - 2026-07-05
### Added
* `econetTLSInsecure` config option (env `ECONET_TLS_INSECURE`) to skip TLS
certificate verification when
connecting to the Rheem/EcoNet cloud API. The upstream host (cloudblade)
serves an old/untrusted certificate chain; this is a stopgap and should be
removed once the upstream certificate is fixed. See AGENTS.md.
* `TODO.md` tracking outstanding work (CA cert bundle, mutating proto RPCs,
additional tracing spans).
## [v0.1.2] - 2026-07-04
### Added
* CA certificate bundle baked into the container image so the exporter can
establish TLS connections to the EcoNet API.
### Changed
* Bumped Helm chart versions.
## [v0.1.1] - 2026-07-04
### Fixed
* Corrected the Helm chart `appVersion`.
## [v0.1.0] - 2026-07-04
Initial release of the EcoNet exporter for Rheem / Rheemcloud water heaters,
built on the `go-app` framework.
### Added
* `EconetService` owning a single shared `*rheemcloud.Client` across the gRPC,
MCP, and metrics sub-servers (fail-fast on missing credentials).
* gRPC API (`ListDevices`, `GetDevice`) with proto definitions and
grpc-gateway REST bindings.
* MCP server at `/api/mcp` exposing a `list_water_heaters` tool.
* OpenTelemetry observable gauges for device state (temperature, mode, energy
and water usage, cost) with a background usage poller, exported to
Prometheus. Units are encoded in metric names (`_fahrenheit`, `_kwh`,
`_gallons`, `_dollars`).
* `costPerKWH` config (US dollars per kWh) feeding `econet_energy_cost_dollars`.
* Helm chart for Kubernetes deployment.
+2
View File
@@ -19,6 +19,8 @@ FROM alpine:latest
ARG APP_NAME=econet-exporter ARG APP_NAME=econet-exporter
RUN apk add --no-cache ca-certificates
WORKDIR /app WORKDIR /app
USER 100:101 USER 100:101
+31 -9
View File
@@ -1,8 +1,7 @@
# econet-exporter 🔥💧 # econet-exporter 🔥💧
An exporter for **Rheem EcoNet / Rheemcloud** water-heater data. It keeps a An exporter for **Rheem EcoNet / Rheemcloud** water-heater data. It polls the
long-lived connection to the Rheem cloud (via Rheem cloud REST API (ClearBlade backend) on an interval and exposes device
[`rheemcloud-go`](https://github.com/kevinburke/rheemcloud-go)) and exposes device
state and usage three ways: state and usage three ways:
- **OTEL metrics** served for Prometheus scraping at `/metrics` (live state + - **OTEL metrics** served for Prometheus scraping at `/metrics` (live state +
@@ -25,7 +24,7 @@ go-app defaults. Custom fields (see `pkg/config/custom.go`):
| `econetEmail` | `ECONET_EMAIL` | Rheem account email | | `econetEmail` | `ECONET_EMAIL` | Rheem account email |
| `econetPassword` | `ECONET_PASSWORD` | Prefer the env var; keep out of config.yaml | | `econetPassword` | `ECONET_PASSWORD` | Prefer the env var; keep out of config.yaml |
| `costPerKWH` | `ECONET_COST_PER_KWH` | **US dollars per kWh** (e.g. `0.18` = 18¢), not cents | | `costPerKWH` | `ECONET_COST_PER_KWH` | **US dollars per kWh** (e.g. `0.18` = 18¢), not cents |
| `usageInterval` | `ECONET_USAGE_INTERVAL` | Energy/water poll interval (default `5m`) | | `pollInterval` | `ECONET_POLL_INTERVAL` | How often device state + daily energy/water usage are re-fetched over REST (default `1m`) |
Everything under the go-app `AppConfig` (logging, otel, http, grpc) is also Everything under the go-app `AppConfig` (logging, otel, http, grpc) is also
env-overridable with `APP_*` variables — see `env-sample`. env-overridable with `APP_*` variables — see `env-sample`.
@@ -47,20 +46,43 @@ Then:
Per-device gauges labeled `econet.serial`, `econet.device_id`, Per-device gauges labeled `econet.serial`, `econet.device_id`,
`econet.friendly_name`: `econet_setpoint_fahrenheit`, `econet_connected`, `econet.friendly_name`: `econet_setpoint_fahrenheit`, `econet_connected`,
`econet_running`, `econet_enabled`, `econet_away`, `econet_running` (adds a `running_state` label naming the active stage, e.g.
`econet_hot_water_availability_percent`, `econet_alert_count`, `compressor-running` / `element-running` / `idle`), `econet_enabled`,
`econet_wifi_signal_db`, `econet_device_info` (adds `type`/`generic_type`/`mode`). `econet_away`, `econet_hot_water_availability_percent`, `econet_alert_count`,
`econet_wifi_signal_db`, `econet_mode` (enum: `1` for the current mode, `0`
otherwise, via a `mode` label), `econet_device_info` (adds
`type`/`generic_type`/`mode`).
Polled usage: `econet_energy_usage_kwh`, `econet_water_usage_gallons`, Polled usage: `econet_energy_usage_kwh`, `econet_water_usage_gallons`,
`econet_energy_cost_dollars`. `econet_energy_cost_dollars`.
> Note: `econet_wifi_signal_db` is only delivered over Rheem's MQTT push stream,
> which this exporter does not use — it stays at `0`.
### 📈 Grafana dashboard
The Helm chart ships a ready-made Grafana dashboard
(`helm/dashboards/econet-exporter.json`). It is **off by default**; enable it by
setting `hull.config.settings.grafanaDashboard.enabled=true`, which renders a
ConfigMap labeled `grafana_dashboard: "1"` for the [Grafana sidecar](https://github.com/grafana/helm-charts/tree/main/charts/grafana#sidecar-for-dashboards)
to auto-import. The dashboard uses the **default Prometheus datasource** (no
hard-coded UID) and has a multi-select **Device** filter backed by
`econet_friendly_name`. Tunables under `grafanaDashboard`: `label` / `labelValue`
(match your sidecar's watch config) and `folder` (target Grafana folder).
```sh
helm upgrade --install econet ./helm \
--set hull.config.settings.grafanaDashboard.enabled=true
```
## 📂 Project Structure ## 📂 Project Structure
- `proto/econet/v1alpha1/` - Protobuf definitions (source). - `proto/econet/v1alpha1/` - Protobuf definitions (source).
- `api/econet/v1alpha1/` - Generated proto, grpc, grpc-gateway code. - `api/econet/v1alpha1/` - Generated proto, grpc, grpc-gateway code.
- `pkg/config/` - Custom config merged with go-app configuration. - `pkg/config/` - Custom config merged with go-app configuration.
- `pkg/econet/` - `EconetService` (owns the rheemcloud client) plus: - `pkg/econet/` - `EconetService` (owns the REST client + poll ticker) plus:
- `econetclient/` - minimal EcoNet REST client (auth, devices, usage).
- `econetgrpc/` - gRPC `EconetService` implementation. - `econetgrpc/` - gRPC `EconetService` implementation.
- `econetmcp/` - MCP server + tools. - `econetmcp/` - MCP server + tools.
- `econetmetrics/` - OTEL observable gauges + usage poller. - `econetmetrics/` - OTEL observable gauges.
- `helm/` - Helm chart for Kubernetes deployment. - `helm/` - Helm chart for Kubernetes deployment.
- `.gitea/workflows/` - CI pipelines. - `.gitea/workflows/` - CI pipelines.
+6 -9
View File
@@ -1,10 +1,7 @@
# Demo app TODO # econet-exporter TODO
- [ ] Fix Docerfile for ca cert bundle (TLS error to API host)
- [ ] Add mutating proto RPCs (change mode, etc..)
- [ ] Add spans to things
- [x] Create generic interface for implenting a service
- [x] Create config sample not called demo, so it is more easily reused
- [x] Update README for tagging/versioning/pipeline info
- [x] Update README for detail on installing protoc tools and make
- [x] Rename project
- [x] Finish grpc sample implementation
- [x] Add Dockerfile
- [x] Add gitea CI
+218 -23
View File
@@ -24,6 +24,77 @@ const (
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
) )
// Mode is a settable operating mode for a water heater. The values mirror the
// kebab-case slugs reported in Device.mode. Not every device supports every
// mode; the supported set is device-specific (derived from the unit's reported
// mode list), so SetMode validates against the target device.
type Mode int32
const (
Mode_MODE_UNSPECIFIED Mode = 0
Mode_MODE_OFF Mode = 1
Mode_MODE_ELECTRIC Mode = 2
Mode_MODE_ENERGY_SAVING Mode = 3
Mode_MODE_HEAT_PUMP Mode = 4
Mode_MODE_HIGH_DEMAND Mode = 5
Mode_MODE_GAS Mode = 6
Mode_MODE_PERFORMANCE Mode = 7
Mode_MODE_VACATION Mode = 8
)
// Enum value maps for Mode.
var (
Mode_name = map[int32]string{
0: "MODE_UNSPECIFIED",
1: "MODE_OFF",
2: "MODE_ELECTRIC",
3: "MODE_ENERGY_SAVING",
4: "MODE_HEAT_PUMP",
5: "MODE_HIGH_DEMAND",
6: "MODE_GAS",
7: "MODE_PERFORMANCE",
8: "MODE_VACATION",
}
Mode_value = map[string]int32{
"MODE_UNSPECIFIED": 0,
"MODE_OFF": 1,
"MODE_ELECTRIC": 2,
"MODE_ENERGY_SAVING": 3,
"MODE_HEAT_PUMP": 4,
"MODE_HIGH_DEMAND": 5,
"MODE_GAS": 6,
"MODE_PERFORMANCE": 7,
"MODE_VACATION": 8,
}
)
func (x Mode) Enum() *Mode {
p := new(Mode)
*p = x
return p
}
func (x Mode) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (Mode) Descriptor() protoreflect.EnumDescriptor {
return file_econet_v1alpha1_econet_proto_enumTypes[0].Descriptor()
}
func (Mode) Type() protoreflect.EnumType {
return &file_econet_v1alpha1_econet_proto_enumTypes[0]
}
func (x Mode) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use Mode.Descriptor instead.
func (Mode) EnumDescriptor() ([]byte, []int) {
return file_econet_v1alpha1_econet_proto_rawDescGZIP(), []int{0}
}
// Device is the live state of a single Rheem EcoNet device // Device is the live state of a single Rheem EcoNet device
// (typically a water heater) as reported by the Rheem cloud. // (typically a water heater) as reported by the Rheem cloud.
type Device struct { type Device struct {
@@ -374,6 +445,104 @@ func (x *GetDeviceResponse) GetDevice() *Device {
return nil return nil
} }
type SetModeRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
SerialNumber string `protobuf:"bytes,1,opt,name=serial_number,json=serialNumber,proto3" json:"serial_number,omitempty"`
Mode Mode `protobuf:"varint,2,opt,name=mode,proto3,enum=econet.v1alpha1.Mode" json:"mode,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SetModeRequest) Reset() {
*x = SetModeRequest{}
mi := &file_econet_v1alpha1_econet_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SetModeRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SetModeRequest) ProtoMessage() {}
func (x *SetModeRequest) ProtoReflect() protoreflect.Message {
mi := &file_econet_v1alpha1_econet_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SetModeRequest.ProtoReflect.Descriptor instead.
func (*SetModeRequest) Descriptor() ([]byte, []int) {
return file_econet_v1alpha1_econet_proto_rawDescGZIP(), []int{5}
}
func (x *SetModeRequest) GetSerialNumber() string {
if x != nil {
return x.SerialNumber
}
return ""
}
func (x *SetModeRequest) GetMode() Mode {
if x != nil {
return x.Mode
}
return Mode_MODE_UNSPECIFIED
}
type SetModeResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Last-known device state. The mode change is applied asynchronously by the
// Rheem cloud, so this reflects the requested mode only after a later poll.
Device *Device `protobuf:"bytes,1,opt,name=device,proto3" json:"device,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SetModeResponse) Reset() {
*x = SetModeResponse{}
mi := &file_econet_v1alpha1_econet_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SetModeResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SetModeResponse) ProtoMessage() {}
func (x *SetModeResponse) ProtoReflect() protoreflect.Message {
mi := &file_econet_v1alpha1_econet_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SetModeResponse.ProtoReflect.Descriptor instead.
func (*SetModeResponse) Descriptor() ([]byte, []int) {
return file_econet_v1alpha1_econet_proto_rawDescGZIP(), []int{6}
}
func (x *SetModeResponse) GetDevice() *Device {
if x != nil {
return x.Device
}
return nil
}
var File_econet_v1alpha1_econet_proto protoreflect.FileDescriptor var File_econet_v1alpha1_econet_proto protoreflect.FileDescriptor
const file_econet_v1alpha1_econet_proto_rawDesc = "" + const file_econet_v1alpha1_econet_proto_rawDesc = "" +
@@ -407,10 +576,27 @@ const file_econet_v1alpha1_econet_proto_rawDesc = "" +
"\x10GetDeviceRequest\x12,\n" + "\x10GetDeviceRequest\x12,\n" +
"\rserial_number\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\fserialNumber\"D\n" + "\rserial_number\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\fserialNumber\"D\n" +
"\x11GetDeviceResponse\x12/\n" + "\x11GetDeviceResponse\x12/\n" +
"\x06device\x18\x01 \x01(\v2\x17.econet.v1alpha1.DeviceR\x06device2\x83\x02\n" + "\x06device\x18\x01 \x01(\v2\x17.econet.v1alpha1.DeviceR\x06device\"u\n" +
"\x0eSetModeRequest\x12,\n" +
"\rserial_number\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\fserialNumber\x125\n" +
"\x04mode\x18\x02 \x01(\x0e2\x15.econet.v1alpha1.ModeB\n" +
"\xbaH\a\x82\x01\x04\x10\x01 \x00R\x04mode\"B\n" +
"\x0fSetModeResponse\x12/\n" +
"\x06device\x18\x01 \x01(\v2\x17.econet.v1alpha1.DeviceR\x06device*\xb6\x01\n" +
"\x04Mode\x12\x14\n" +
"\x10MODE_UNSPECIFIED\x10\x00\x12\f\n" +
"\bMODE_OFF\x10\x01\x12\x11\n" +
"\rMODE_ELECTRIC\x10\x02\x12\x16\n" +
"\x12MODE_ENERGY_SAVING\x10\x03\x12\x12\n" +
"\x0eMODE_HEAT_PUMP\x10\x04\x12\x14\n" +
"\x10MODE_HIGH_DEMAND\x10\x05\x12\f\n" +
"\bMODE_GAS\x10\x06\x12\x14\n" +
"\x10MODE_PERFORMANCE\x10\a\x12\x11\n" +
"\rMODE_VACATION\x10\b2\x84\x03\n" +
"\rEconetService\x12s\n" + "\rEconetService\x12s\n" +
"\vListDevices\x12#.econet.v1alpha1.ListDevicesRequest\x1a$.econet.v1alpha1.ListDevicesResponse\"\x19\x82\xd3\xe4\x93\x02\x13\x12\x11/v1alpha1/devices\x12}\n" + "\vListDevices\x12#.econet.v1alpha1.ListDevicesRequest\x1a$.econet.v1alpha1.ListDevicesResponse\"\x19\x82\xd3\xe4\x93\x02\x13\x12\x11/v1alpha1/devices\x12}\n" +
"\tGetDevice\x12!.econet.v1alpha1.GetDeviceRequest\x1a\".econet.v1alpha1.GetDeviceResponse\")\x82\xd3\xe4\x93\x02#\x12!/v1alpha1/devices/{serial_number}B\xcb\x01\n" + "\tGetDevice\x12!.econet.v1alpha1.GetDeviceRequest\x1a\".econet.v1alpha1.GetDeviceResponse\")\x82\xd3\xe4\x93\x02#\x12!/v1alpha1/devices/{serial_number}\x12\x7f\n" +
"\aSetMode\x12\x1f.econet.v1alpha1.SetModeRequest\x1a .econet.v1alpha1.SetModeResponse\"1\x82\xd3\xe4\x93\x02+:\x01*\"&/v1alpha1/devices/{serial_number}/modeB\xcb\x01\n" +
"\x13com.econet.v1alpha1B\vEconetProtoP\x01ZJgitea.libretechconsulting.com/rmcguire/econet-exporter/api/econet/v1alpha1\xa2\x02\x03EXX\xaa\x02\x0fEconet.V1alpha1\xca\x02\x0fEconet\\V1alpha1\xe2\x02\x1bEconet\\V1alpha1\\GPBMetadata\xea\x02\x10Econet::V1alpha1b\x06proto3" "\x13com.econet.v1alpha1B\vEconetProtoP\x01ZJgitea.libretechconsulting.com/rmcguire/econet-exporter/api/econet/v1alpha1\xa2\x02\x03EXX\xaa\x02\x0fEconet.V1alpha1\xca\x02\x0fEconet\\V1alpha1\xe2\x02\x1bEconet\\V1alpha1\\GPBMetadata\xea\x02\x10Econet::V1alpha1b\x06proto3"
var ( var (
@@ -425,28 +611,36 @@ func file_econet_v1alpha1_econet_proto_rawDescGZIP() []byte {
return file_econet_v1alpha1_econet_proto_rawDescData return file_econet_v1alpha1_econet_proto_rawDescData
} }
var file_econet_v1alpha1_econet_proto_msgTypes = make([]protoimpl.MessageInfo, 5) var file_econet_v1alpha1_econet_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
var file_econet_v1alpha1_econet_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
var file_econet_v1alpha1_econet_proto_goTypes = []any{ var file_econet_v1alpha1_econet_proto_goTypes = []any{
(*Device)(nil), // 0: econet.v1alpha1.Device (Mode)(0), // 0: econet.v1alpha1.Mode
(*ListDevicesRequest)(nil), // 1: econet.v1alpha1.ListDevicesRequest (*Device)(nil), // 1: econet.v1alpha1.Device
(*ListDevicesResponse)(nil), // 2: econet.v1alpha1.ListDevicesResponse (*ListDevicesRequest)(nil), // 2: econet.v1alpha1.ListDevicesRequest
(*GetDeviceRequest)(nil), // 3: econet.v1alpha1.GetDeviceRequest (*ListDevicesResponse)(nil), // 3: econet.v1alpha1.ListDevicesResponse
(*GetDeviceResponse)(nil), // 4: econet.v1alpha1.GetDeviceResponse (*GetDeviceRequest)(nil), // 4: econet.v1alpha1.GetDeviceRequest
(*timestamppb.Timestamp)(nil), // 5: google.protobuf.Timestamp (*GetDeviceResponse)(nil), // 5: econet.v1alpha1.GetDeviceResponse
(*SetModeRequest)(nil), // 6: econet.v1alpha1.SetModeRequest
(*SetModeResponse)(nil), // 7: econet.v1alpha1.SetModeResponse
(*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp
} }
var file_econet_v1alpha1_econet_proto_depIdxs = []int32{ var file_econet_v1alpha1_econet_proto_depIdxs = []int32{
5, // 0: econet.v1alpha1.Device.last_updated:type_name -> google.protobuf.Timestamp 8, // 0: econet.v1alpha1.Device.last_updated:type_name -> google.protobuf.Timestamp
0, // 1: econet.v1alpha1.ListDevicesResponse.devices:type_name -> econet.v1alpha1.Device 1, // 1: econet.v1alpha1.ListDevicesResponse.devices:type_name -> econet.v1alpha1.Device
0, // 2: econet.v1alpha1.GetDeviceResponse.device:type_name -> econet.v1alpha1.Device 1, // 2: econet.v1alpha1.GetDeviceResponse.device:type_name -> econet.v1alpha1.Device
1, // 3: econet.v1alpha1.EconetService.ListDevices:input_type -> econet.v1alpha1.ListDevicesRequest 0, // 3: econet.v1alpha1.SetModeRequest.mode:type_name -> econet.v1alpha1.Mode
3, // 4: econet.v1alpha1.EconetService.GetDevice:input_type -> econet.v1alpha1.GetDeviceRequest 1, // 4: econet.v1alpha1.SetModeResponse.device:type_name -> econet.v1alpha1.Device
2, // 5: econet.v1alpha1.EconetService.ListDevices:output_type -> econet.v1alpha1.ListDevicesResponse 2, // 5: econet.v1alpha1.EconetService.ListDevices:input_type -> econet.v1alpha1.ListDevicesRequest
4, // 6: econet.v1alpha1.EconetService.GetDevice:output_type -> econet.v1alpha1.GetDeviceResponse 4, // 6: econet.v1alpha1.EconetService.GetDevice:input_type -> econet.v1alpha1.GetDeviceRequest
5, // [5:7] is the sub-list for method output_type 6, // 7: econet.v1alpha1.EconetService.SetMode:input_type -> econet.v1alpha1.SetModeRequest
3, // [3:5] is the sub-list for method input_type 3, // 8: econet.v1alpha1.EconetService.ListDevices:output_type -> econet.v1alpha1.ListDevicesResponse
3, // [3:3] is the sub-list for extension type_name 5, // 9: econet.v1alpha1.EconetService.GetDevice:output_type -> econet.v1alpha1.GetDeviceResponse
3, // [3:3] is the sub-list for extension extendee 7, // 10: econet.v1alpha1.EconetService.SetMode:output_type -> econet.v1alpha1.SetModeResponse
0, // [0:3] is the sub-list for field type_name 8, // [8:11] is the sub-list for method output_type
5, // [5:8] is the sub-list for method input_type
5, // [5:5] is the sub-list for extension type_name
5, // [5:5] is the sub-list for extension extendee
0, // [0:5] is the sub-list for field type_name
} }
func init() { file_econet_v1alpha1_econet_proto_init() } func init() { file_econet_v1alpha1_econet_proto_init() }
@@ -459,13 +653,14 @@ func file_econet_v1alpha1_econet_proto_init() {
File: protoimpl.DescBuilder{ File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_econet_v1alpha1_econet_proto_rawDesc), len(file_econet_v1alpha1_econet_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_econet_v1alpha1_econet_proto_rawDesc), len(file_econet_v1alpha1_econet_proto_rawDesc)),
NumEnums: 0, NumEnums: 1,
NumMessages: 5, NumMessages: 7,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },
GoTypes: file_econet_v1alpha1_econet_proto_goTypes, GoTypes: file_econet_v1alpha1_econet_proto_goTypes,
DependencyIndexes: file_econet_v1alpha1_econet_proto_depIdxs, DependencyIndexes: file_econet_v1alpha1_econet_proto_depIdxs,
EnumInfos: file_econet_v1alpha1_econet_proto_enumTypes,
MessageInfos: file_econet_v1alpha1_econet_proto_msgTypes, MessageInfos: file_econet_v1alpha1_econet_proto_msgTypes,
}.Build() }.Build()
File_econet_v1alpha1_econet_proto = out.File File_econet_v1alpha1_econet_proto = out.File
+84
View File
@@ -95,6 +95,51 @@ func local_request_EconetService_GetDevice_0(ctx context.Context, marshaler runt
return msg, metadata, err return msg, metadata, err
} }
func request_EconetService_SetMode_0(ctx context.Context, marshaler runtime.Marshaler, client EconetServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq SetModeRequest
metadata runtime.ServerMetadata
err error
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
val, ok := pathParams["serial_number"]
if !ok {
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "serial_number")
}
protoReq.SerialNumber, err = runtime.String(val)
if err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "serial_number", err)
}
msg, err := client.SetMode(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_EconetService_SetMode_0(ctx context.Context, marshaler runtime.Marshaler, server EconetServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq SetModeRequest
metadata runtime.ServerMetadata
err error
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
val, ok := pathParams["serial_number"]
if !ok {
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "serial_number")
}
protoReq.SerialNumber, err = runtime.String(val)
if err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "serial_number", err)
}
msg, err := server.SetMode(ctx, &protoReq)
return msg, metadata, err
}
// RegisterEconetServiceHandlerServer registers the http handlers for service EconetService to "mux". // RegisterEconetServiceHandlerServer registers the http handlers for service EconetService to "mux".
// UnaryRPC :call EconetServiceServer directly. // UnaryRPC :call EconetServiceServer directly.
// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. // StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906.
@@ -141,6 +186,26 @@ func RegisterEconetServiceHandlerServer(ctx context.Context, mux *runtime.ServeM
} }
forward_EconetService_GetDevice_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) forward_EconetService_GetDevice_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
}) })
mux.Handle(http.MethodPost, pattern_EconetService_SetMode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/econet.v1alpha1.EconetService/SetMode", runtime.WithHTTPPathPattern("/v1alpha1/devices/{serial_number}/mode"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_EconetService_SetMode_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_EconetService_SetMode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
return nil return nil
} }
@@ -215,15 +280,34 @@ func RegisterEconetServiceHandlerClient(ctx context.Context, mux *runtime.ServeM
} }
forward_EconetService_GetDevice_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) forward_EconetService_GetDevice_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
}) })
mux.Handle(http.MethodPost, pattern_EconetService_SetMode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/econet.v1alpha1.EconetService/SetMode", runtime.WithHTTPPathPattern("/v1alpha1/devices/{serial_number}/mode"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_EconetService_SetMode_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_EconetService_SetMode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
return nil return nil
} }
var ( var (
pattern_EconetService_ListDevices_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1alpha1", "devices"}, "")) pattern_EconetService_ListDevices_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1alpha1", "devices"}, ""))
pattern_EconetService_GetDevice_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"v1alpha1", "devices", "serial_number"}, "")) pattern_EconetService_GetDevice_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"v1alpha1", "devices", "serial_number"}, ""))
pattern_EconetService_SetMode_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"v1alpha1", "devices", "serial_number", "mode"}, ""))
) )
var ( var (
forward_EconetService_ListDevices_0 = runtime.ForwardResponseMessage forward_EconetService_ListDevices_0 = runtime.ForwardResponseMessage
forward_EconetService_GetDevice_0 = runtime.ForwardResponseMessage forward_EconetService_GetDevice_0 = runtime.ForwardResponseMessage
forward_EconetService_SetMode_0 = runtime.ForwardResponseMessage
) )
+38
View File
@@ -21,6 +21,7 @@ const _ = grpc.SupportPackageIsVersion9
const ( const (
EconetService_ListDevices_FullMethodName = "/econet.v1alpha1.EconetService/ListDevices" EconetService_ListDevices_FullMethodName = "/econet.v1alpha1.EconetService/ListDevices"
EconetService_GetDevice_FullMethodName = "/econet.v1alpha1.EconetService/GetDevice" EconetService_GetDevice_FullMethodName = "/econet.v1alpha1.EconetService/GetDevice"
EconetService_SetMode_FullMethodName = "/econet.v1alpha1.EconetService/SetMode"
) )
// EconetServiceClient is the client API for EconetService service. // EconetServiceClient is the client API for EconetService service.
@@ -31,6 +32,7 @@ const (
type EconetServiceClient interface { type EconetServiceClient interface {
ListDevices(ctx context.Context, in *ListDevicesRequest, opts ...grpc.CallOption) (*ListDevicesResponse, error) ListDevices(ctx context.Context, in *ListDevicesRequest, opts ...grpc.CallOption) (*ListDevicesResponse, error)
GetDevice(ctx context.Context, in *GetDeviceRequest, opts ...grpc.CallOption) (*GetDeviceResponse, error) GetDevice(ctx context.Context, in *GetDeviceRequest, opts ...grpc.CallOption) (*GetDeviceResponse, error)
SetMode(ctx context.Context, in *SetModeRequest, opts ...grpc.CallOption) (*SetModeResponse, error)
} }
type econetServiceClient struct { type econetServiceClient struct {
@@ -61,6 +63,16 @@ func (c *econetServiceClient) GetDevice(ctx context.Context, in *GetDeviceReques
return out, nil return out, nil
} }
func (c *econetServiceClient) SetMode(ctx context.Context, in *SetModeRequest, opts ...grpc.CallOption) (*SetModeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SetModeResponse)
err := c.cc.Invoke(ctx, EconetService_SetMode_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// EconetServiceServer is the server API for EconetService service. // EconetServiceServer is the server API for EconetService service.
// All implementations should embed UnimplementedEconetServiceServer // All implementations should embed UnimplementedEconetServiceServer
// for forward compatibility. // for forward compatibility.
@@ -69,6 +81,7 @@ func (c *econetServiceClient) GetDevice(ctx context.Context, in *GetDeviceReques
type EconetServiceServer interface { type EconetServiceServer interface {
ListDevices(context.Context, *ListDevicesRequest) (*ListDevicesResponse, error) ListDevices(context.Context, *ListDevicesRequest) (*ListDevicesResponse, error)
GetDevice(context.Context, *GetDeviceRequest) (*GetDeviceResponse, error) GetDevice(context.Context, *GetDeviceRequest) (*GetDeviceResponse, error)
SetMode(context.Context, *SetModeRequest) (*SetModeResponse, error)
} }
// UnimplementedEconetServiceServer should be embedded to have // UnimplementedEconetServiceServer should be embedded to have
@@ -84,6 +97,9 @@ func (UnimplementedEconetServiceServer) ListDevices(context.Context, *ListDevice
func (UnimplementedEconetServiceServer) GetDevice(context.Context, *GetDeviceRequest) (*GetDeviceResponse, error) { func (UnimplementedEconetServiceServer) GetDevice(context.Context, *GetDeviceRequest) (*GetDeviceResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetDevice not implemented") return nil, status.Error(codes.Unimplemented, "method GetDevice not implemented")
} }
func (UnimplementedEconetServiceServer) SetMode(context.Context, *SetModeRequest) (*SetModeResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetMode not implemented")
}
func (UnimplementedEconetServiceServer) testEmbeddedByValue() {} func (UnimplementedEconetServiceServer) testEmbeddedByValue() {}
// UnsafeEconetServiceServer may be embedded to opt out of forward compatibility for this service. // UnsafeEconetServiceServer may be embedded to opt out of forward compatibility for this service.
@@ -140,6 +156,24 @@ func _EconetService_GetDevice_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _EconetService_SetMode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetModeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(EconetServiceServer).SetMode(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: EconetService_SetMode_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(EconetServiceServer).SetMode(ctx, req.(*SetModeRequest))
}
return interceptor(ctx, in, info, handler)
}
// EconetService_ServiceDesc is the grpc.ServiceDesc for EconetService service. // EconetService_ServiceDesc is the grpc.ServiceDesc for EconetService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@@ -155,6 +189,10 @@ var EconetService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetDevice", MethodName: "GetDevice",
Handler: _EconetService_GetDevice_Handler, Handler: _EconetService_GetDevice_Handler,
}, },
{
MethodName: "SetMode",
Handler: _EconetService_SetMode_Handler,
},
}, },
Streams: []grpc.StreamDesc{}, Streams: []grpc.StreamDesc{},
Metadata: "econet/v1alpha1/econet.proto", Metadata: "econet/v1alpha1/econet.proto",
File diff suppressed because one or more lines are too long
+4
View File
@@ -24,5 +24,9 @@ plugins:
opt: opt:
- merge_file_name=econet-exporter - merge_file_name=econet-exporter
- allow_merge=true - allow_merge=true
- local: ["go", "run", "github.com/redpanda-data/protoc-gen-go-mcp/cmd/protoc-gen-go-mcp@latest"]
out: api
opt:
- paths=source_relative
inputs: inputs:
- directory: proto - directory: proto
+2 -2
View File
@@ -2,8 +2,8 @@
version: v2 version: v2
deps: deps:
- name: buf.build/bufbuild/protovalidate - name: buf.build/bufbuild/protovalidate
commit: 50325440f8f24053b047484a6bf60b76 commit: 435963d1631043e694e56e6bcc3c79c3
digest: b5:74cb6f5c0853c3c10aafc701614194bbd63326bdb8ef4068214454b8894b03ba4113e04b3a33a8321cdf05336e37db4dc14a5e2495db8462566914f36086ba31 digest: b5:f4ea07ad2dd94bd7243562f9908b9fb104feef8076040c89d9f7c1dedc074de4d4ce2b997686ef4400f3eccb765a7cfc20ed4acdd70b9a3699351245c61dba97
- name: buf.build/googleapis/googleapis - name: buf.build/googleapis/googleapis
commit: c17df5b2beca46928cc87d5656bd5343 commit: c17df5b2beca46928cc87d5656bd5343
digest: b5:648a01e0170d4512dea7d564016165decd1ed6e34bef79fe54753e51ad7e27545709ad9157d7551270147d551155c595a2fb0bf5bb33b1c83040ddbce915c604 digest: b5:648a01e0170d4512dea7d564016165decd1ed6e34bef79fe54753e51ad7e27545709ad9157d7551270147d551155c595a2fb0bf5bb33b1c83040ddbce915c604
+1 -1
View File
@@ -4,7 +4,7 @@
# NOTE: Prefer setting econetPassword via the ECONET_PASSWORD env var # NOTE: Prefer setting econetPassword via the ECONET_PASSWORD env var
econetEmail: "" econetEmail: ""
costPerKWH: 0.0 # US dollars per kWh (e.g. 0.18 = 18 cents), not cents costPerKWH: 0.0 # US dollars per kWh (e.g. 0.18 = 18 cents), not cents
usageInterval: 5m pollInterval: 1m # how often device state + daily usage are re-fetched over REST
# go-app config # go-app config
name: econet-exporter name: econet-exporter
+71
View File
@@ -67,9 +67,55 @@
"EconetService" "EconetService"
] ]
} }
},
"/v1alpha1/devices/{serialNumber}/mode": {
"post": {
"operationId": "EconetService_SetMode",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/v1alpha1SetModeResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "serialNumber",
"in": "path",
"required": true,
"type": "string"
},
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/EconetServiceSetModeBody"
}
}
],
"tags": [
"EconetService"
]
}
} }
}, },
"definitions": { "definitions": {
"EconetServiceSetModeBody": {
"type": "object",
"properties": {
"mode": {
"$ref": "#/definitions/v1alpha1Mode"
}
}
},
"protobufAny": { "protobufAny": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -190,6 +236,31 @@
} }
} }
} }
},
"v1alpha1Mode": {
"type": "string",
"enum": [
"MODE_UNSPECIFIED",
"MODE_OFF",
"MODE_ELECTRIC",
"MODE_ENERGY_SAVING",
"MODE_HEAT_PUMP",
"MODE_HIGH_DEMAND",
"MODE_GAS",
"MODE_PERFORMANCE",
"MODE_VACATION"
],
"default": "MODE_UNSPECIFIED",
"description": "Mode is a settable operating mode for a water heater. The values mirror the\nkebab-case slugs reported in Device.mode. Not every device supports every\nmode; the supported set is device-specific (derived from the unit's reported\nmode list), so SetMode validates against the target device."
},
"v1alpha1SetModeResponse": {
"type": "object",
"properties": {
"device": {
"$ref": "#/definitions/v1alpha1Device",
"description": "Last-known device state. The mode change is applied asynchronously by the\nRheem cloud, so this reflects the requested mode only after a later poll."
}
}
} }
} }
} }
+6 -1
View File
@@ -124,6 +124,7 @@
}, },
"properties": { "properties": {
"costPerKWH": { "costPerKWH": {
"default": 0.19,
"type": "number" "type": "number"
}, },
"econetEmail": { "econetEmail": {
@@ -132,6 +133,10 @@
"econetPassword": { "econetPassword": {
"type": "string" "type": "string"
}, },
"econetTLSInsecure": {
"default": false,
"type": "boolean"
},
"environment": { "environment": {
"type": "string" "type": "string"
}, },
@@ -150,7 +155,7 @@
"otel": { "otel": {
"$ref": "#/definitions/ConfigOTELConfig" "$ref": "#/definitions/ConfigOTELConfig"
}, },
"usageInterval": { "pollInterval": {
"type": "integer" "type": "integer"
}, },
"version": { "version": {
+5 -6
View File
@@ -1,13 +1,14 @@
module gitea.libretechconsulting.com/rmcguire/econet-exporter module gitea.libretechconsulting.com/rmcguire/econet-exporter
go 1.26 go 1.26.1
require ( require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1
connectrpc.com/connect v1.20.0
gitea.libretechconsulting.com/rmcguire/go-app v0.17.1 gitea.libretechconsulting.com/rmcguire/go-app v0.17.1
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0
github.com/kevinburke/rheemcloud-go v0.1.1
github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/redpanda-data/protoc-gen-go-mcp v0.0.0-20260709214304-372b2d56e470
github.com/rs/zerolog v1.35.1 github.com/rs/zerolog v1.35.1
go.opentelemetry.io/otel/metric v1.44.0 go.opentelemetry.io/otel/metric v1.44.0
go.opentelemetry.io/otel/trace v1.44.0 go.opentelemetry.io/otel/trace v1.44.0
@@ -37,20 +38,18 @@ require (
) )
require ( require (
buf.build/gen/go/redpandadata/common/protocolbuffers/go v1.34.2-20240917150400-3f349e63f44a.2 // indirect
buf.build/go/protovalidate v1.2.0 // indirect buf.build/go/protovalidate v1.2.0 // indirect
cel.dev/expr v0.25.2 // indirect cel.dev/expr v0.25.2 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/cel-go v0.29.1 // indirect github.com/google/cel-go v0.29.1 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/kevinburke/rest v0.0.0-20260604005914-d145d8a0294b // indirect
github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
@@ -59,6 +58,7 @@ require (
github.com/prometheus/common v0.69.0 // indirect github.com/prometheus/common v0.69.0 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect
github.com/redpanda-data/common-go/api v0.0.0-20250801174835-9eea07f1ea06 // indirect
github.com/rs/cors v1.11.1 // indirect github.com/rs/cors v1.11.1 // indirect
github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/asm v1.2.1 // indirect
github.com/segmentio/encoding v0.5.4 // indirect github.com/segmentio/encoding v0.5.4 // indirect
@@ -70,7 +70,6 @@ require (
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/net v0.56.0 // indirect golang.org/x/net v0.56.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect golang.org/x/text v0.38.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect
+10 -10
View File
@@ -1,9 +1,13 @@
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
buf.build/gen/go/redpandadata/common/protocolbuffers/go v1.34.2-20240917150400-3f349e63f44a.2 h1:JyGBchZNUPlQ7/qjieeKq/Cy+/i1vc0H+cIniGZNSFg=
buf.build/gen/go/redpandadata/common/protocolbuffers/go v1.34.2-20240917150400-3f349e63f44a.2/go.mod h1:wThyg02xJx4K/DA5fg0QlKts8XVPyTT86JC8hPfEzno=
buf.build/go/protovalidate v1.2.0 h1:DQVrUWkmGTBij+kOYv/x2LLxwcLaGKMdzShj1/6/3H0= buf.build/go/protovalidate v1.2.0 h1:DQVrUWkmGTBij+kOYv/x2LLxwcLaGKMdzShj1/6/3H0=
buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4= buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4=
cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
gitea.libretechconsulting.com/rmcguire/go-app v0.17.1 h1:OdUNAXRoDpB5sC3KdaJC7Rav8MmxiRZC0fRx9YNH1Ks= gitea.libretechconsulting.com/rmcguire/go-app v0.17.1 h1:OdUNAXRoDpB5sC3KdaJC7Rav8MmxiRZC0fRx9YNH1Ks=
gitea.libretechconsulting.com/rmcguire/go-app v0.17.1/go.mod h1:YBygVa9JBYAP+ibF8FevN1KUe3h5GHWxAdFyU6I8QgA= gitea.libretechconsulting.com/rmcguire/go-app v0.17.1/go.mod h1:YBygVa9JBYAP+ibF8FevN1KUe3h5GHWxAdFyU6I8QgA=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
@@ -24,8 +28,6 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -45,18 +47,12 @@ github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc=
github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE=
github.com/kevinburke/rest v0.0.0-20260604005914-d145d8a0294b h1:9ZWwhlz+lmPlXCz37dkRVMgK+iIwkFyQBogC2oWds/A=
github.com/kevinburke/rest v0.0.0-20260604005914-d145d8a0294b/go.mod h1:x35n2Fa5sM/IwpMDk6B9f7n6FkcBFX3yV9diIRfqxQc=
github.com/kevinburke/rheemcloud-go v0.1.1 h1:PsrpOso9rEeyATlAfDHjKx5E8C2TJseDwfeRvw4uzH4=
github.com/kevinburke/rheemcloud-go v0.1.1/go.mod h1:17QQqbT/YPQDnR3m+67EV7Wuve0onhKeiLAMcy1br1U=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -73,6 +69,8 @@ github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/u
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
@@ -85,6 +83,10 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/redpanda-data/common-go/api v0.0.0-20250801174835-9eea07f1ea06 h1:9Ecc+Cg1EyqSTIQ6wQKoKk8BqDlBQmR74bJui4qIqsM=
github.com/redpanda-data/common-go/api v0.0.0-20250801174835-9eea07f1ea06/go.mod h1:klAmWfc8Q3hEZk8geFTMu6f2sk3VUKRS7cv/LvB05ig=
github.com/redpanda-data/protoc-gen-go-mcp v0.0.0-20260709214304-372b2d56e470 h1:gBvQBXc+MtBa8aGjXTOFLPu+1a+7I0UMu6Np9ofY1Ow=
github.com/redpanda-data/protoc-gen-go-mcp v0.0.0-20260709214304-372b2d56e470/go.mod h1:1Ulz0OS7NpChjyMu6NWZucui6SEbIsOzAshGfpqWCV8=
github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8=
github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -157,8 +159,6 @@ golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+3 -3
View File
@@ -15,16 +15,16 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes # This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version. # to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/) # Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.3 version: 0.2.10
# This is the version number of the application being deployed. This version number should be # This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to # incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using. # follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes. # It is recommended to use it with quotes.
appVersion: "v0.1.0" appVersion: "v0.3.0"
dependencies: dependencies:
- name: hull - name: hull
repository: https://vidispine.github.io/hull repository: https://vidispine.github.io/hull
version: 1.32.2 version: 1.36.0
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{{- $gd := .Values.hull.config.settings.grafanaDashboard | default dict }}
{{- if $gd.enabled }}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Release.Name }}-grafana-dashboard
namespace: {{ .Release.Namespace }}
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- /* Label the Grafana sidecar watches for to auto-import dashboards */}}
{{ $gd.label | default "grafana_dashboard" }}: {{ $gd.labelValue | default "1" | quote }}
{{- with $gd.folder }}
annotations:
grafana_folder: {{ . | quote }}
{{- end }}
data:
econet-exporter.json: |-
{{ .Files.Get "dashboards/econet-exporter.json" | indent 4 }}
{{- end }}
+13 -1
View File
@@ -7,7 +7,7 @@ hull:
# NOTE: Prefer supplying econetPassword via the ECONET_PASSWORD env var # NOTE: Prefer supplying econetPassword via the ECONET_PASSWORD env var
econetEmail: "" econetEmail: ""
costPerKWH: 0.19 costPerKWH: 0.19
usageInterval: 5m pollInterval: 1m
# go-app config # go-app config
name: econet-exporter name: econet-exporter
logging: logging:
@@ -58,6 +58,18 @@ hull:
otelResourceAttributes: app=econet-exporter otelResourceAttributes: app=econet-exporter
otlpEndpoint: http://otel.otel.svc.cluster.local:4317 # Replace me otlpEndpoint: http://otel.otel.svc.cluster.local:4317 # Replace me
# Ship a ready-made Grafana dashboard as a ConfigMap for the Grafana
# sidecar to auto-import (kiwigrid/k8s-sidecar). Flip enabled to true;
# the dashboard uses the default Prometheus datasource and filters by
# device name. No datasource UID hard-coding required.
grafanaDashboard:
enabled: false
# Label/value the Grafana sidecar is configured to watch for.
label: grafana_dashboard
labelValue: "1"
# Optional Grafana folder to file the dashboard under (blank = General).
folder: ""
serviceType: ClusterIP serviceType: ClusterIP
serviceLbIP: "" # Used if serviceTyps=LoadBalancer serviceLbIP: "" # Used if serviceTyps=LoadBalancer
+14 -10
View File
@@ -12,9 +12,9 @@ import (
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/config" "gitea.libretechconsulting.com/rmcguire/go-app/pkg/config"
) )
// DefaultUsageInterval is used when UsageInterval is unset. Rheem's // DefaultPollInterval is used when PollInterval is unset. Each tick re-fetches
// energy/water history only updates hourly, so polling faster is wasteful. // device state and daily energy/water usage over REST.
const DefaultUsageInterval = 5 * time.Minute const DefaultPollInterval = 1 * time.Minute
type ServiceConfig struct { type ServiceConfig struct {
// Rheem EcoNet cloud credentials. Prefer supplying the password via // Rheem EcoNet cloud credentials. Prefer supplying the password via
@@ -22,13 +22,17 @@ type ServiceConfig struct {
EconetEmail string `yaml:"econetEmail" json:"econetEmail,omitempty" env:"ECONET_EMAIL"` EconetEmail string `yaml:"econetEmail" json:"econetEmail,omitempty" env:"ECONET_EMAIL"`
EconetPassword string `yaml:"econetPassword" json:"econetPassword,omitempty" env:"ECONET_PASSWORD"` EconetPassword string `yaml:"econetPassword" json:"econetPassword,omitempty" env:"ECONET_PASSWORD"`
// Necessary due to ancient TLS certificate run by cloudblade that isn't trusted in modern ca-certificate bundles
EconetTLSInsecure bool `yaml:"econetTLSInsecure" json:"econetTLSInsecure,omitempty" env:"ECONET_TLS_INSECURE" default:"false"`
// CostPerKWH is the electricity price in US dollars per kWh (e.g. 0.18 // CostPerKWH is the electricity price in US dollars per kWh (e.g. 0.18
// means 18 cents/kWh, NOT 18). It derives the econet_energy_cost_dollars // means 18 cents/kWh, NOT 18). It derives the econet_energy_cost_dollars
// metric from kWh energy usage. // metric from kWh energy usage.
CostPerKWH float64 `yaml:"costPerKWH" json:"costPerKWH,omitempty" env:"ECONET_COST_PER_KWH" default:"0.19"` CostPerKWH float64 `yaml:"costPerKWH" json:"costPerKWH,omitempty" env:"ECONET_COST_PER_KWH" default:"0.19"`
// UsageInterval controls how often energy/water usage history is polled. // PollInterval controls how often device state and daily energy/water
UsageInterval time.Duration `yaml:"usageInterval" json:"usageInterval,omitempty" env:"ECONET_USAGE_INTERVAL"` // usage are re-fetched over REST.
PollInterval time.Duration `yaml:"pollInterval" json:"pollInterval,omitempty" env:"ECONET_POLL_INTERVAL"`
// Embeds go-app config, used by go-app to // Embeds go-app config, used by go-app to
// merge custom config into go-app config, and to produce // merge custom config into go-app config, and to produce
@@ -47,10 +51,10 @@ func (c *ServiceConfig) LoadEnv() error {
return env.Parse(c) return env.Parse(c)
} }
// GetUsageInterval returns the configured usage poll interval or the default. // GetPollInterval returns the configured poll interval or the default.
func (c *ServiceConfig) GetUsageInterval() time.Duration { func (c *ServiceConfig) GetPollInterval() time.Duration {
if c.UsageInterval <= 0 { if c.PollInterval <= 0 {
return DefaultUsageInterval return DefaultPollInterval
} }
return c.UsageInterval return c.PollInterval
} }
+48 -23
View File
@@ -6,16 +6,21 @@ package econet
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog" "time"
rheemcloud "github.com/kevinburke/rheemcloud-go" "github.com/rs/zerolog"
optsgrpc "gitea.libretechconsulting.com/rmcguire/go-app/pkg/srv/grpc/opts" optsgrpc "gitea.libretechconsulting.com/rmcguire/go-app/pkg/srv/grpc/opts"
optshttp "gitea.libretechconsulting.com/rmcguire/go-app/pkg/srv/http/opts" optshttp "gitea.libretechconsulting.com/rmcguire/go-app/pkg/srv/http/opts"
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/config" "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/config"
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetclient"
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetgrpc" "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetgrpc"
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetmcp" // MCP server. Aliased so switching implementations is a one-line change.
// Generated (protoc-gen-go-mcp) tools:
econetmcp "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetmcpgen"
// Hand-written tools (switch back by using this import instead):
// econetmcp "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetmcp"
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetmetrics" "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetmetrics"
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/service" "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/service"
) )
@@ -23,7 +28,8 @@ import (
type EconetService struct { type EconetService struct {
ctx context.Context ctx context.Context
config *config.ServiceConfig config *config.ServiceConfig
client *rheemcloud.Client log *zerolog.Logger
client *econetclient.Client
grpc *econetgrpc.EconetGRPCServer grpc *econetgrpc.EconetGRPCServer
mcp *econetmcp.EconetMCPServer mcp *econetmcp.EconetMCPServer
metrics *econetmetrics.Collector metrics *econetmetrics.Collector
@@ -32,39 +38,58 @@ type EconetService struct {
func (e *EconetService) Init(ctx context.Context, cfg *config.ServiceConfig) (service.ShutdownFunc, error) { func (e *EconetService) Init(ctx context.Context, cfg *config.ServiceConfig) (service.ShutdownFunc, error) {
e.ctx = ctx e.ctx = ctx
e.config = cfg e.config = cfg
e.log = zerolog.Ctx(ctx)
client, err := connect(ctx, cfg) // Fail fast on missing credentials (cheap, no network).
if err != nil { if cfg.EconetEmail == "" || cfg.EconetPassword == "" {
return nil, err return nil, fmt.Errorf("econet: email and password required (set ECONET_EMAIL / ECONET_PASSWORD)")
} }
e.client = client
e.grpc = econetgrpc.NewEconetGRPCServer(ctx, cfg, client) e.client = econetclient.New(cfg, e.log)
e.grpc = econetgrpc.NewEconetGRPCServer(ctx, cfg, e.client)
e.mcp = econetmcp.NewEconetMCPServer(ctx, cfg, e.grpc) e.mcp = econetmcp.NewEconetMCPServer(ctx, cfg, e.grpc)
e.metrics = econetmetrics.NewCollector(ctx, cfg, client) e.metrics = econetmetrics.NewCollector(ctx, cfg, e.client)
if err := e.metrics.Start(); err != nil { if err := e.metrics.RegisterGauges(); err != nil {
return nil, err return nil, err
} }
// Poll the cloud in the background so a slow or unreachable Rheem cloud
// never blocks HTTP/gRPC server startup. Devices are empty until the first
// successful refresh; the gauges and gRPC handlers tolerate that.
go e.pollLoop(ctx)
return e.shutdown, nil return e.shutdown, nil
} }
func connect(ctx context.Context, cfg *config.ServiceConfig) (*rheemcloud.Client, error) { // pollLoop refreshes device state on a ticker for the life of the service.
if cfg.EconetEmail == "" || cfg.EconetPassword == "" { // A per-refresh timeout keeps a hung REST call from stalling the loop.
return nil, fmt.Errorf("econet: email and password required (set ECONET_EMAIL / ECONET_PASSWORD)") func (e *EconetService) pollLoop(ctx context.Context) {
interval := e.config.GetPollInterval()
e.refresh(ctx)
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
e.refresh(ctx)
} }
client, err := rheemcloud.Connect(ctx, cfg.EconetEmail, cfg.EconetPassword, &rheemcloud.Config{
Logger: slog.Default(),
})
if err != nil {
return nil, fmt.Errorf("econet: connect failed: %w", err)
} }
return client, nil }
func (e *EconetService) refresh(ctx context.Context) {
rctx, cancel := context.WithTimeout(ctx, e.config.GetPollInterval())
defer cancel()
if err := e.client.Refresh(rctx); err != nil {
e.log.Error().Err(err).Msg("econet: refresh failed")
return
}
e.log.Debug().Int("devices", len(e.client.Devices())).Msg("econet: refreshed")
} }
func (e *EconetService) shutdown(_ context.Context) (string, error) { func (e *EconetService) shutdown(_ context.Context) (string, error) {
e.metrics.Stop() return "EconetService", nil
return "EconetService", e.client.Close()
} }
func (e *EconetService) GetGRPC() *optsgrpc.AppGRPC { func (e *EconetService) GetGRPC() *optsgrpc.AppGRPC {
@@ -86,7 +111,7 @@ func (e *EconetService) healthChecks() []optshttp.HealthCheckFunc {
return []optshttp.HealthCheckFunc{ return []optshttp.HealthCheckFunc{
func(_ context.Context) error { func(_ context.Context) error {
if len(e.client.Devices()) == 0 { if len(e.client.Devices()) == 0 {
return fmt.Errorf("econet: no devices loaded") return fmt.Errorf("econet: no devices loaded yet")
} }
return nil return nil
}, },
+382
View File
@@ -0,0 +1,382 @@
// Package econetclient is a minimal REST client for the Rheem EcoNet cloud
// (ClearBlade backend). It authenticates, lists devices, and reads daily
// energy/water usage over plain HTTPS — no MQTT. Callers drive it by calling
// Refresh on a ticker and reading the decoded Device snapshots.
package econetclient
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/rs/zerolog"
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/config"
)
// Static credentials embedded in the Rheem EcoNet Android app; the ClearBlade
// backend uses them to identify the calling app independent of the per-user
// auth. Same values pyeconet and kevinburke/rheemcloud-go use.
const (
clearBladeSystemKey = "e2e699cb0bb0bbb88fc8858cb5a401"
clearBladeSystemSecret = "E2E699CB0BE6C6FADDB1B0BC9A20"
baseURL = "https://rheem.clearblade.com/api/v/1"
)
// Client talks to the EcoNet REST API and caches the latest device snapshot.
// It is safe for concurrent use: Refresh swaps the device map under a lock,
// and readers take a read lock.
type Client struct {
email string
password string
costPerKWH float64
http *http.Client
log *zerolog.Logger
mu sync.RWMutex
token string
accountID string
devices map[string]*Device
}
// New builds a Client from config. It performs no network I/O; call Refresh
// to authenticate and populate devices.
func New(cfg *config.ServiceConfig, log *zerolog.Logger) *Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: cfg.EconetTLSInsecure} //nolint:gosec // opt-in via EconetTLSInsecure
return &Client{
email: cfg.EconetEmail,
password: cfg.EconetPassword,
costPerKWH: cfg.CostPerKWH,
http: &http.Client{Transport: transport, Timeout: 15 * time.Second},
log: log,
devices: map[string]*Device{},
}
}
// CostPerKWH exposes the configured electricity price for the usage gauges.
func (c *Client) CostPerKWH() float64 { return c.costPerKWH }
// Devices returns a snapshot slice of the currently-known devices. Empty until
// the first successful Refresh.
func (c *Client) Devices() []*Device {
c.mu.RLock()
defer c.mu.RUnlock()
out := make([]*Device, 0, len(c.devices))
for _, d := range c.devices {
out = append(out, d)
}
return out
}
// Device looks up a single device by serial number, or nil if unknown.
func (c *Client) Device(serial string) *Device {
c.mu.RLock()
defer c.mu.RUnlock()
return c.devices[serial]
}
// Refresh authenticates if needed, re-fetches the device list, reads each
// unit's daily usage, and atomically replaces the cached snapshot. On an auth
// failure it clears the token so the next call re-authenticates.
func (c *Client) Refresh(ctx context.Context) error {
if err := c.ensureAuth(ctx); err != nil {
return err
}
equipment, err := c.fetchDevices(ctx)
if err != nil {
c.clearToken() // force re-auth next time in case the token expired
return err
}
now := time.Now()
devices := make(map[string]*Device, len(equipment))
for _, raw := range equipment {
d := newDevice(raw, now)
if d.SerialNumber == "" {
continue
}
c.fetchUsage(ctx, d)
devices[d.SerialNumber] = d
}
c.mu.Lock()
c.devices = devices
c.mu.Unlock()
return nil
}
// SetMode changes a device's operating mode. It resolves the normalized mode
// slug to the device's @MODE enum index and publishes a desired-state message
// to the EcoNet cloud over ClearBlade's REST publish endpoint (the HTTP
// equivalent of the app's MQTT publish). The change is applied asynchronously;
// the new mode is only reflected after a subsequent Refresh.
func (c *Client) SetMode(ctx context.Context, serial, mode string) error {
c.mu.RLock()
d, account := c.devices[serial], c.accountID
c.mu.RUnlock()
if d == nil {
return fmt.Errorf("econet: unknown device %q", serial)
}
idx, ok := d.modeIndex(mode)
if !ok {
return fmt.Errorf("econet: device %q does not support mode %q (supported: %s)",
serial, mode, strings.Join(d.SupportedModes(), ", "))
}
if err := c.ensureAuth(ctx); err != nil {
return err
}
desired, err := json.Marshal(map[string]any{
"transactionId": transactionID(),
"device_name": d.DeviceID,
"serial_number": d.SerialNumber,
"@MODE": idx,
})
if err != nil {
return err
}
publish := map[string]any{
"topic": "user/" + account + "/device/desired",
"body": string(desired),
"qos": 1,
}
path := "/message/" + clearBladeSystemKey + "/publish"
if err := c.do(ctx, path, c.userToken(), publish, nil); err != nil {
c.clearToken() // token may have expired; re-auth next call
return fmt.Errorf("econet: set mode: %w", err)
}
c.log.Info().Str("serial", serial).Str("mode", mode).Int("modeIndex", idx).
Msg("econet: published mode change")
return nil
}
func (c *Client) clearToken() {
c.mu.Lock()
c.token = ""
c.mu.Unlock()
}
func (c *Client) ensureAuth(ctx context.Context) error {
c.mu.RLock()
have := c.token != ""
c.mu.RUnlock()
if have {
return nil
}
return c.authenticate(ctx)
}
// --- REST calls ----------------------------------------------------------
func (c *Client) authenticate(ctx context.Context) error {
body := map[string]string{"email": c.email, "password": c.password}
var ar struct {
UserToken string `json:"user_token"`
Options struct {
Success bool `json:"success"`
AccountID string `json:"account_id"`
Message string `json:"message"`
} `json:"options"`
}
if err := c.do(ctx, "/user/auth", "", body, &ar); err != nil {
return fmt.Errorf("econet: auth: %w", err)
}
if !ar.Options.Success || ar.UserToken == "" || ar.Options.AccountID == "" {
if ar.Options.Message != "" {
return fmt.Errorf("econet: auth rejected: %s", ar.Options.Message)
}
return fmt.Errorf("econet: auth rejected (empty token/account)")
}
c.mu.Lock()
c.token = ar.UserToken
c.accountID = ar.Options.AccountID
c.mu.Unlock()
return nil
}
// fetchDevices returns the raw equipment blocks from getUserDataForApp.
func (c *Client) fetchDevices(ctx context.Context) ([]map[string]json.RawMessage, error) {
var resp struct {
Results struct {
Locations []struct {
// "equiptments" is misspelled upstream; match it exactly.
Equipments []map[string]json.RawMessage `json:"equiptments"`
} `json:"locations"`
} `json:"results"`
}
path := "/code/" + clearBladeSystemKey + "/getUserDataForApp"
if err := c.do(ctx, path, c.userToken(), map[string]string{"resource": "friedrich"}, &resp); err != nil {
return nil, fmt.Errorf("econet: device list: %w", err)
}
var out []map[string]json.RawMessage
for _, loc := range resp.Results.Locations {
out = append(out, loc.Equipments...)
}
return out, nil
}
// fetchUsage reads the device's daily energy and water totals, filling the
// usage fields. Failures are logged at debug and leave the fields at zero
// (e.g. units that don't track water return an error).
func (c *Client) fetchUsage(ctx context.Context, d *Device) {
if e, typ, err := c.usage(ctx, d, "energyUsage"); err == nil {
d.EnergyKWH, d.EnergyType = e, typ
} else {
c.log.Debug().Err(err).Str("serial", d.SerialNumber).Msg("energy usage poll failed")
}
if w, _, err := c.usage(ctx, d, "waterUsage"); err == nil {
d.WaterGallons = w
} else {
c.log.Debug().Err(err).Str("serial", d.SerialNumber).Msg("water usage poll failed")
}
}
// usage POSTs a dynamicAction usage report and sums today's buckets. The
// energy unit ("KWH"/"KBTU") is inferred from the summary message.
func (c *Client) usage(ctx context.Context, d *Device, usageType string) (total float64, energyType string, err error) {
start, end := dayWindow(usageType == "energyUsage")
payload := map[string]string{
"ACTION": "waterheaterUsageReportView",
"device_name": d.DeviceID,
"serial_number": d.SerialNumber,
"start_date": start,
"end_date": end,
"usage_type": usageType,
}
var raw json.RawMessage
path := "/code/" + clearBladeSystemKey + "/dynamicAction"
if err := c.do(ctx, path, c.userToken(), payload, &raw); err != nil {
return 0, "", err
}
var env struct {
Success bool `json:"success"`
Error string `json:"error"`
Results struct {
Energy usageBlock `json:"energy_usage"`
Water usageBlock `json:"water_usage"`
} `json:"results"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return 0, "", fmt.Errorf("parse usage: %w", err)
}
if !env.Success {
if env.Error != "" {
return 0, "", fmt.Errorf("usage rejected: %s", env.Error)
}
return 0, "", fmt.Errorf("usage rejected")
}
block := env.Results.Water
if usageType == "energyUsage" {
block = env.Results.Energy
}
for _, p := range block.Data {
total += p.Value
}
return total, energyTypeFromMessage(block.Message, d.GenericType), nil
}
type usageBlock struct {
Data []struct {
Value float64 `json:"value"`
} `json:"data"`
Message string `json:"message"`
}
// do POSTs a JSON body to path and decodes the JSON response into out. When
// token is non-empty it is sent as the ClearBlade-UserToken header.
func (c *Client) do(ctx context.Context, path, token string, body, out any) error {
b, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("ClearBlade-SystemKey", clearBladeSystemKey)
req.Header.Set("ClearBlade-SystemSecret", clearBladeSystemSecret)
if token != "" {
req.Header.Set("ClearBlade-UserToken", token)
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, bytes.TrimSpace(raw))
}
// The publish endpoint replies 200 with an empty (or non-JSON) body; callers
// that don't need the response pass out == nil.
if out == nil || len(bytes.TrimSpace(raw)) == 0 {
return nil
}
return json.Unmarshal(raw, out)
}
func (c *Client) userToken() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.token
}
// --- usage helpers -------------------------------------------------------
// transactionID mirrors the identifier the Rheem Android app stamps on each
// desired-state publish: "ANDROID_" + a local second-resolution timestamp.
func transactionID() string {
return "ANDROID_" + time.Now().Format("2006-01-02T15:04:05")
}
// dayWindow returns ISO-8601 start/end strings covering the current local day,
// matching pyeconet's formats: energy includes the timezone offset, water does
// not (the upstream parser is picky).
func dayWindow(energy bool) (start, end string) {
now := time.Now()
s := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 999_000_000, now.Location())
e := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 999_000_000, now.Location())
if energy {
return pyISOMillis(s), pyISOMillis(e)
}
const f = "2006-01-02T15:04:05.999"
return s.Format(f), e.Format(f)
}
// pyISOMillis emulates Python's datetime.isoformat(timespec='milliseconds')
// for a tz-aware datetime: YYYY-MM-DDTHH:MM:SS.mmm±HH:MM.
func pyISOMillis(t time.Time) string {
s := t.Format("2006-01-02T15:04:05.000-0700")
if len(s) >= 5 { // "-0700" -> "-07:00"
s = s[:len(s)-2] + ":" + s[len(s)-2:]
}
return s
}
// energyTypeFromMessage extracts the unit label ("KWH"/"KBTU") from Rheem's
// human summary ("You used X.Y kWh of energy today."), the 4th word per
// pyeconet's heuristic, falling back to the device's generic type.
func energyTypeFromMessage(msg, genericType string) string {
fields := strings.Fields(msg)
if len(fields) >= 4 {
return strings.ToUpper(fields[3])
}
if genericType == "gasWaterHeater" {
return "KBTU"
}
return "KWH"
}
+349
View File
@@ -0,0 +1,349 @@
package econetclient
import (
"encoding/json"
"strconv"
"strings"
"time"
)
// Device is a flat snapshot of one piece of Rheem equipment, decoded from
// the getUserDataForApp REST response. Fields are computed once per refresh;
// consumers read them directly. WiFiSignal only arrives over MQTT (which this
// client does not use), so it stays at its zero value.
type Device struct {
SerialNumber string
DeviceID string
FriendlyName string
Type string // water_heater | thermostat | unknown
GenericType string // e.g. heatpumpWaterHeater, gasWaterHeater
Connected bool
WiFiSignal int // MQTT-only; always 0 here
Mode string
Enabled bool
Running bool // true when actively heating (decoded from @RUNNING over REST)
RunningState string // normalized @RUNNING label: "compressor-running" | "element-running" | "idle" | ...
// modeEnumText is the raw @MODE enum label list; the slice index is the
// integer value published to change the mode. Empty for units without a
// settable @MODE (mode changes are unsupported for those).
modeEnumText []string
Setpoint int
SetpointMin int
SetpointMax int
HotWaterAvailability int // -1 if not reported, else 0/33/66/100
AlertCount int
Away bool
LastUpdated time.Time
// Usage totals for the current day, populated by Client.Refresh.
EnergyKWH float64
EnergyType string // "KWH" or "KBTU"
WaterGallons float64
}
// newDevice decodes an equipment block (the raw JSON map for one unit) into
// a flat Device. It ports the accessor logic from kevinburke/rheemcloud-go
// so behaviour matches, minus the MQTT-only fields.
func newDevice(info map[string]json.RawMessage, now time.Time) *Device {
d := &Device{
DeviceID: jsonStr(info["device_name"]),
SerialNumber: jsonStr(info["serial_number"]),
Type: deviceTypeString(jsonStr(info["device_type"])),
GenericType: jsonStr(info["@TYPE"]),
Connected: connected(info),
Away: jsonBool(info["@AWAY"]),
AlertCount: alertCount(info["@ALERTCOUNT"]),
HotWaterAvailability: hotWater(info["@HOTWATER"]),
Enabled: enabled(info),
Mode: mode(info),
LastUpdated: now,
}
d.FriendlyName = friendlyName(info, d.DeviceID)
d.Setpoint, d.SetpointMin, d.SetpointMax = setpoint(info["@SETPOINT"])
d.Running, d.RunningState = running(info)
d.modeEnumText = modeLabels(info)
return d
}
// SupportedModes returns the normalized mode slugs this device can be set to,
// in publish-index order. Empty for units without a settable @MODE.
func (d *Device) SupportedModes() []string {
out := make([]string, 0, len(d.modeEnumText))
for _, label := range d.modeEnumText {
out = append(out, modeFromEnumText(label))
}
return out
}
// modeIndex maps a normalized mode slug to the integer value the EcoNet cloud
// expects in a @MODE publish, matching pyeconet's enumText-position lookup.
func (d *Device) modeIndex(slug string) (int, bool) {
for i, label := range d.modeEnumText {
if modeFromEnumText(label) == slug {
return i, true
}
}
return 0, false
}
// --- field decoders ------------------------------------------------------
func connected(info map[string]json.RawMessage) bool {
v, ok := info["@CONNECTED"]
if !ok {
return true // older units omit it and are assumed connected
}
return jsonBool(v)
}
func friendlyName(info map[string]json.RawMessage, deviceID string) string {
if obj := decodeDatapoint(info["@NAME"]); obj != nil {
if s := jsonStr(obj.Value); s != "" {
return s
}
}
return deviceID
}
func alertCount(v json.RawMessage) int {
if len(v) == 0 {
return 0
}
var n float64
if json.Unmarshal(v, &n) == nil {
return int(n)
}
var s string
if json.Unmarshal(v, &s) == nil {
if i, err := strconv.Atoi(s); err == nil {
return i
}
}
return 0
}
func hotWater(v json.RawMessage) int {
var icon string
if len(v) == 0 || json.Unmarshal(v, &icon) != nil {
return -1
}
switch {
case strings.Contains(icon, "ic_tank_hundread_percent"):
return 100
case strings.Contains(icon, "ic_tank_fourty_percent"):
return 66
case strings.Contains(icon, "ic_tank_ten_percent"):
return 33
case strings.Contains(icon, "ic_tank_empty"), strings.Contains(icon, "ic_tank_zero_percent"):
return 0
default:
return -1
}
}
func setpoint(v json.RawMessage) (val, min, max int) {
obj := decodeDatapoint(v)
if obj == nil {
return 0, 0, 0
}
var n float64
_ = json.Unmarshal(obj.Value, &n)
return int(n), int(obj.Constraints.LowerLimit), int(obj.Constraints.UpperLimit)
}
// enabled reports the master on/off state, porting rheemcloud's Device.Enabled.
func enabled(info map[string]json.RawMessage) bool {
if _, ok := info["@MODE"]; ok {
labels := modeLabels(info)
if obj := decodeDatapoint(info["@MODE"]); obj != nil {
if idx, ok := datapointIndex(obj); ok && idx >= 0 && idx < len(labels) {
return !strings.EqualFold(labels[idx], "OFF")
}
}
}
if _, ok := info["@ENABLED"]; ok {
obj := decodeDatapoint(info["@ENABLED"])
if obj == nil {
return false
}
idx, _ := datapointIndex(obj)
return idx == 1
}
return false
}
// mode returns the current operating-mode label, porting rheemcloud's Device.Mode.
func mode(info map[string]json.RawMessage) string {
_, supportsModes := info["@MODE"]
_, supportsOnOff := info["@ENABLED"]
if supportsOnOff && !enabled(info) {
return "off"
}
if !supportsModes {
switch jsonStr(info["@TYPE"]) {
case "gasWaterHeater", "tanklessWaterHeater":
return "gas"
default:
return "electric"
}
}
labels := modeLabels(info)
obj := decodeDatapoint(info["@MODE"])
if obj == nil {
return "unknown"
}
idx, _ := datapointIndex(obj)
if idx < 0 || idx >= len(labels) {
return "unknown"
}
return modeFromEnumText(labels[idx])
}
func modeLabels(info map[string]json.RawMessage) []string {
obj := decodeDatapoint(info["@MODE"])
if obj == nil {
return nil
}
return obj.Constraints.EnumText
}
// modeFromEnumText normalizes a Rheem mode label into a stable slug,
// matching pyeconet's WaterHeaterOperationMode labels.
func modeFromEnumText(s string) string {
cleaned := strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(strings.TrimRight(s, " "), " ", "_"), "/", "_"))
switch cleaned {
case "OFF":
return "off"
case "ELECTRIC", "ELECTRIC_MODE":
return "electric"
case "ENERGY_SAVING", "ENERGY_SAVER":
return "energy-saving"
case "HEAT_PUMP", "HEAT_PUMP_ONLY":
return "heat-pump"
case "HIGH_DEMAND":
return "high-demand"
case "GAS":
return "gas"
case "PERFORMANCE":
return "performance"
case "VACATION":
return "vacation"
default:
return "unknown"
}
}
// KnownModes are the normalized operating-mode slugs modeFromEnumText emits.
// Consumers (e.g. the mode enum gauge) iterate these so every mode gets a
// series regardless of which one is currently active.
var KnownModes = []string{
"off", "electric", "energy-saving", "heat-pump",
"high-demand", "gas", "performance", "vacation", "unknown",
}
// running reports whether the unit is actively heating and a normalized label
// for what is running. It reads the @RUNNING datapoint, a bare string the REST
// getUserDataForApp payload populates (e.g. "Compressor Running" -> true,
// "compressor-running"); empty means idle. This is served over plain REST
// despite older code assuming it was MQTT-only.
func running(info map[string]json.RawMessage) (active bool, state string) {
raw := strings.TrimSpace(jsonStr(info["@RUNNING"]))
if raw == "" {
return false, "idle"
}
state = slugify(raw)
if state == "off" || state == "idle" {
return false, state
}
return true, state
}
// slugify normalizes a Rheem label into a stable lowercase kebab-case slug,
// e.g. "Compressor Running" -> "compressor-running".
func slugify(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.ReplaceAll(s, "/", "-")
s = strings.Join(strings.Fields(s), "-")
return s
}
func deviceTypeString(s string) string {
switch s {
case "WH":
return "water_heater"
case "HVAC":
return "thermostat"
default:
return "unknown"
}
}
// --- datapoint plumbing --------------------------------------------------
// datapointObject is the {value, status, title, constraints} shape many
// "@..." fields use. Some fields are bare scalars instead; decodeDatapoint
// returns nil for those and callers fall back to reading the raw value.
type datapointObject struct {
Value json.RawMessage `json:"value"`
Status string `json:"status"`
Title string `json:"title"`
Constraints datapointConstraints `json:"constraints"`
}
type datapointConstraints struct {
LowerLimit float64 `json:"lowerLimit"`
UpperLimit float64 `json:"upperLimit"`
EnumText []string `json:"enumText"`
}
func decodeDatapoint(b json.RawMessage) *datapointObject {
if len(b) == 0 || !isJSONObject(b) {
return nil
}
var obj datapointObject
if json.Unmarshal(b, &obj) != nil {
return nil
}
return &obj
}
// datapointIndex reads a datapoint's numeric value (mode index / enabled flag).
func datapointIndex(obj *datapointObject) (int, bool) {
var n float64
if json.Unmarshal(obj.Value, &n) != nil {
return 0, false
}
return int(n), true
}
func isJSONObject(b json.RawMessage) bool {
for _, c := range b {
switch c {
case ' ', '\t', '\n', '\r':
continue
case '{':
return true
default:
return false
}
}
return false
}
func jsonStr(v json.RawMessage) string {
var s string
_ = json.Unmarshal(v, &s)
return s
}
func jsonBool(v json.RawMessage) bool {
var b bool
_ = json.Unmarshal(v, &b)
return b
}
+19 -20
View File
@@ -1,32 +1,31 @@
package econetgrpc package econetgrpc
import ( import (
rheemcloud "github.com/kevinburke/rheemcloud-go"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
pb "gitea.libretechconsulting.com/rmcguire/econet-exporter/api/econet/v1alpha1" pb "gitea.libretechconsulting.com/rmcguire/econet-exporter/api/econet/v1alpha1"
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetclient"
) )
func deviceToProto(d *rheemcloud.Device) *pb.Device { func deviceToProto(d *econetclient.Device) *pb.Device {
min, max := d.SetpointLimits()
return &pb.Device{ return &pb.Device{
SerialNumber: d.SerialNumber(), SerialNumber: d.SerialNumber,
DeviceId: d.DeviceID(), DeviceId: d.DeviceID,
FriendlyName: d.FriendlyName(), FriendlyName: d.FriendlyName,
Type: d.Type().String(), Type: d.Type,
GenericType: d.GenericType(), GenericType: d.GenericType,
Connected: d.Connected(), Connected: d.Connected,
WifiSignal: int32(d.WiFiSignal()), WifiSignal: int32(d.WiFiSignal),
Mode: d.Mode().String(), Mode: d.Mode,
Enabled: d.Enabled(), Enabled: d.Enabled,
Running: d.Running(), Running: d.Running,
RunningState: d.RunningState(), RunningState: d.RunningState,
Setpoint: int32(d.Setpoint()), Setpoint: int32(d.Setpoint),
SetpointMin: int32(min), SetpointMin: int32(d.SetpointMin),
SetpointMax: int32(max), SetpointMax: int32(d.SetpointMax),
HotWaterAvailability: int32(d.HotWaterAvailability()), HotWaterAvailability: int32(d.HotWaterAvailability),
AlertCount: int32(d.AlertCount()), AlertCount: int32(d.AlertCount),
Away: d.Away(), Away: d.Away,
LastUpdated: timestamppb.New(d.LastUpdated), LastUpdated: timestamppb.New(d.LastUpdated),
} }
} }
+50 -4
View File
@@ -1,11 +1,10 @@
// Package econetgrpc implements the EconetService gRPC API over a shared // Package econetgrpc implements the EconetService gRPC API over a shared
// rheemcloud.Client, exposing read-only device state. // econetclient.Client, exposing read-only device state.
package econetgrpc package econetgrpc
import ( import (
"context" "context"
rheemcloud "github.com/kevinburke/rheemcloud-go"
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace"
@@ -16,17 +15,18 @@ import (
pb "gitea.libretechconsulting.com/rmcguire/econet-exporter/api/econet/v1alpha1" pb "gitea.libretechconsulting.com/rmcguire/econet-exporter/api/econet/v1alpha1"
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/config" "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/config"
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetclient"
) )
type EconetGRPCServer struct { type EconetGRPCServer struct {
tracer trace.Tracer tracer trace.Tracer
ctx context.Context ctx context.Context
cfg *config.ServiceConfig cfg *config.ServiceConfig
client *rheemcloud.Client client *econetclient.Client
pb.UnimplementedEconetServiceServer pb.UnimplementedEconetServiceServer
} }
func NewEconetGRPCServer(ctx context.Context, cfg *config.ServiceConfig, client *rheemcloud.Client) *EconetGRPCServer { func NewEconetGRPCServer(ctx context.Context, cfg *config.ServiceConfig, client *econetclient.Client) *EconetGRPCServer {
return &EconetGRPCServer{ return &EconetGRPCServer{
ctx: ctx, ctx: ctx,
cfg: cfg, cfg: cfg,
@@ -70,3 +70,49 @@ func (s *EconetGRPCServer) GetDevice(ctx context.Context, req *pb.GetDeviceReque
span.SetStatus(codes.Ok, "") span.SetStatus(codes.Ok, "")
return &pb.GetDeviceResponse{Device: deviceToProto(d)}, nil return &pb.GetDeviceResponse{Device: deviceToProto(d)}, nil
} }
// modeSlugs maps the settable proto Mode enum to the normalized slugs the
// client (and Device.mode) use. MODE_UNSPECIFIED is intentionally absent;
// protovalidate rejects it before we get here.
var modeSlugs = map[pb.Mode]string{
pb.Mode_MODE_OFF: "off",
pb.Mode_MODE_ELECTRIC: "electric",
pb.Mode_MODE_ENERGY_SAVING: "energy-saving",
pb.Mode_MODE_HEAT_PUMP: "heat-pump",
pb.Mode_MODE_HIGH_DEMAND: "high-demand",
pb.Mode_MODE_GAS: "gas",
pb.Mode_MODE_PERFORMANCE: "performance",
pb.Mode_MODE_VACATION: "vacation",
}
func (s *EconetGRPCServer) SetMode(ctx context.Context, req *pb.SetModeRequest) (
*pb.SetModeResponse, error,
) {
ctx, span := s.tracer.Start(ctx, "setMode", trace.WithAttributes(
attribute.String("serialNumber", req.GetSerialNumber()),
attribute.String("mode", req.GetMode().String()),
))
defer span.End()
slug, ok := modeSlugs[req.GetMode()]
if !ok {
err := status.Errorf(grpccodes.InvalidArgument, "unsupported mode %q", req.GetMode())
span.SetStatus(codes.Error, err.Error())
return nil, err
}
if err := s.client.SetMode(ctx, req.GetSerialNumber(), slug); err != nil {
err := status.Error(grpccodes.FailedPrecondition, err.Error())
span.SetStatus(codes.Error, err.Error())
return nil, err
}
span.SetStatus(codes.Ok, "")
// State applies asynchronously; return the last-known snapshot. Guard against
// a concurrent refresh evicting the device between publish and lookup.
resp := &pb.SetModeResponse{}
if d := s.client.Device(req.GetSerialNumber()); d != nil {
resp.Device = deviceToProto(d)
}
return resp, nil
}
-76
View File
@@ -1,76 +0,0 @@
package econetmcp
import (
"context"
"fmt"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
"k8s.io/utils/ptr"
pb "gitea.libretechconsulting.com/rmcguire/econet-exporter/api/econet/v1alpha1"
)
var ListDevicesTool = &mcp.Tool{
Name: "list_water_heaters",
Title: "List EcoNet Water Heaters",
Description: "Lists Rheem EcoNet devices and their current state (mode, setpoint, " +
"hot water availability, connectivity). Optionally filter to one serial number.",
Annotations: &mcp.ToolAnnotations{
ReadOnlyHint: true,
OpenWorldHint: ptr.To(true),
},
}
type ListDevicesParams struct {
SerialNumber string `json:"serial_number,omitempty" jsonschema:"Optional device serial number to return a single device"`
}
func (m *EconetMCPServer) addListDevicesTool() {
mcp.AddTool(m.server, ListDevicesTool, m.listDevicesHandler)
}
func (m *EconetMCPServer) listDevicesHandler(ctx context.Context, _ *mcp.CallToolRequest, args *ListDevicesParams) (
*mcp.CallToolResult, any, error,
) {
m.log.Debug().Str("serial", args.SerialNumber).Msg("list water heaters tool called")
devices, err := m.lookup(ctx, args.SerialNumber)
if err != nil {
return &mcp.CallToolResult{IsError: true, Meta: mcp.Meta{"error": err.Error()}}, nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: summarizeDevices(devices)}},
}, nil, nil
}
func (m *EconetMCPServer) lookup(ctx context.Context, serial string) ([]*pb.Device, error) {
if serial != "" {
resp, err := m.econetGRPC.GetDevice(ctx, &pb.GetDeviceRequest{SerialNumber: serial})
if err != nil {
return nil, err
}
return []*pb.Device{resp.GetDevice()}, nil
}
resp, err := m.econetGRPC.ListDevices(ctx, &pb.ListDevicesRequest{})
if err != nil {
return nil, err
}
return resp.GetDevices(), nil
}
func summarizeDevices(devices []*pb.Device) string {
if len(devices) == 0 {
return "No EcoNet devices found."
}
var b strings.Builder
for _, d := range devices {
fmt.Fprintf(&b, "%s (%s, serial %s): mode=%s setpoint=%d°F running=%t connected=%t hotWater=%d%% alerts=%d\n",
d.GetFriendlyName(), d.GetGenericType(), d.GetSerialNumber(),
d.GetMode(), d.GetSetpoint(), d.GetRunning(), d.GetConnected(),
d.GetHotWaterAvailability(), d.GetAlertCount())
}
return strings.TrimRight(b.String(), "\n")
}
-38
View File
@@ -1,38 +0,0 @@
package econetmcp
import (
"strings"
"testing"
pb "gitea.libretechconsulting.com/rmcguire/econet-exporter/api/econet/v1alpha1"
)
func TestSummarizeDevices(t *testing.T) {
tests := []struct {
name string
devices []*pb.Device
want string // substring that must appear
}{
{"empty", nil, "No EcoNet devices found."},
{
name: "single",
devices: []*pb.Device{{
FriendlyName: "Garage",
GenericType: "heatpumpWaterHeater",
SerialNumber: "ABC123",
Mode: "heat-pump",
Setpoint: 125,
}},
want: "Garage (heatpumpWaterHeater, serial ABC123): mode=heat-pump setpoint=125",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := summarizeDevices(tt.devices)
if !strings.Contains(got, tt.want) {
t.Errorf("summarizeDevices() = %q, want substring %q", got, tt.want)
}
})
}
}
@@ -1,8 +1,13 @@
/* /*
Package econetmcp exposes an MCP server, mounted as an HTTP handler at Package econetmcpgen exposes an MCP server, mounted as an HTTP handler at
/api/mcp, whose tools wrap the EconetService gRPC API. /api/mcp, whose tools are generated from the EconetService proto by
protoc-gen-go-mcp and forwarded to the in-process gRPC server.
It is the generated-code counterpart to econetmcp (which hand-writes a single
list_water_heaters tool). Both expose an identical package API, so switching
between them is a one-line import change in pkg/econet/econet.go.
*/ */
package econetmcp package econetmcpgen
import ( import (
"context" "context"
@@ -10,8 +15,10 @@ import (
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/srv/http/opts" "gitea.libretechconsulting.com/rmcguire/go-app/pkg/srv/http/opts"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/redpanda-data/protoc-gen-go-mcp/pkg/runtime/gosdk"
"github.com/rs/zerolog" "github.com/rs/zerolog"
v1alpha1mcp "gitea.libretechconsulting.com/rmcguire/econet-exporter/api/econet/v1alpha1/v1alpha1mcp"
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/config" "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/config"
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetgrpc" "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetgrpc"
) )
@@ -46,14 +53,15 @@ func NewEconetMCPServer(ctx context.Context, cfg *config.ServiceConfig,
} }
func (m *EconetMCPServer) GetHandlers() []opts.HTTPHandler { func (m *EconetMCPServer) GetHandlers() []opts.HTTPHandler {
// NOTE: Add other tools here // Register the generated tools against the in-process gRPC server via the
m.addListDevicesTool() // go-sdk adapter, so the generated code shares our configured *mcp.Server.
v1alpha1mcp.RegisterEconetServiceHandler(gosdk.Wrap(m.server), m.econetGRPC)
handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
return m.server return m.server
}, &mcp.StreamableHTTPOptions{}) }, &mcp.StreamableHTTPOptions{})
m.log.Debug().Msg("Econet MCP tools ready") m.log.Debug().Msg("Econet MCP tools ready (generated)")
return []opts.HTTPHandler{ return []opts.HTTPHandler{
{ {
+42 -39
View File
@@ -3,9 +3,10 @@ package econetmetrics
import ( import (
"context" "context"
rheemcloud "github.com/kevinburke/rheemcloud-go"
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/metric"
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetclient"
) )
// registerLive wires the per-device live-state gauges to a single callback // registerLive wires the per-device live-state gauges to a single callback
@@ -17,13 +18,14 @@ func (c *Collector) registerLive() error {
setpointMin: b.i64("econet_setpoint_min_fahrenheit", "Minimum allowed setpoint (°F)"), setpointMin: b.i64("econet_setpoint_min_fahrenheit", "Minimum allowed setpoint (°F)"),
setpointMax: b.i64("econet_setpoint_max_fahrenheit", "Maximum allowed setpoint (°F)"), setpointMax: b.i64("econet_setpoint_max_fahrenheit", "Maximum allowed setpoint (°F)"),
connected: b.i64("econet_connected", "1 if the device is connected"), connected: b.i64("econet_connected", "1 if the device is connected"),
running: b.i64("econet_running", "1 if the device is actively heating"), running: b.i64("econet_running", "1 if the device is actively heating (running_state label names the stage, e.g. compressor-running)"),
mode: b.i64("econet_mode", "Operating mode enum: 1 for the current mode, 0 otherwise (see the mode label)"),
enabled: b.i64("econet_enabled", "1 if the device is enabled"), enabled: b.i64("econet_enabled", "1 if the device is enabled"),
away: b.i64("econet_away", "1 if away mode is on"), away: b.i64("econet_away", "1 if away mode is on"),
hotWater: b.i64("econet_hot_water_availability_percent", "Hot water availability (%)"), hotWater: b.i64("econet_hot_water_availability_percent", "Hot water availability (%)"),
alerts: b.i64("econet_alert_count", "Number of active alerts"), alerts: b.i64("econet_alert_count", "Number of active alerts"),
wifi: b.i64("econet_wifi_signal_db", "WiFi signal strength (dB)"), wifi: b.i64("econet_wifi_signal_db", "WiFi signal strength (dB)"),
lastUpdated: b.i64("econet_last_updated_seconds", "Unix time of last MQTT state update"), lastUpdated: b.i64("econet_last_updated_seconds", "Unix time of last state update"),
info: b.i64("econet_device_info", "Device metadata (value is always 1)"), info: b.i64("econet_device_info", "Device metadata (value is always 1)"),
} }
if b.err != nil { if b.err != nil {
@@ -38,8 +40,8 @@ func (c *Collector) registerLive() error {
return err return err
} }
// registerUsage wires the polled energy/water gauges, reading the cache the // registerUsage wires the energy/water gauges, reading the usage totals the
// poller refreshes (the underlying REST calls are too slow for a callback). // poller folds into each device snapshot.
func (c *Collector) registerUsage() error { func (c *Collector) registerUsage() error {
b := &gaugeBuilder{m: c.meter} b := &gaugeBuilder{m: c.meter}
g := &usageGauges{ g := &usageGauges{
@@ -51,10 +53,8 @@ func (c *Collector) registerUsage() error {
return b.err return b.err
} }
_, err := c.meter.RegisterCallback(func(_ context.Context, o metric.Observer) error { _, err := c.meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
c.mu.RLock() for _, d := range c.client.Devices() {
defer c.mu.RUnlock() g.observe(o, d, c.client.CostPerKWH())
for serial, u := range c.usage {
g.observe(o, serial, u, c.cfg.CostPerKWH)
} }
return nil return nil
}, b.obs...) }, b.obs...)
@@ -65,27 +65,30 @@ type liveGauges struct {
setpoint, setpointMin, setpointMax metric.Int64ObservableGauge setpoint, setpointMin, setpointMax metric.Int64ObservableGauge
connected, running, enabled, away metric.Int64ObservableGauge connected, running, enabled, away metric.Int64ObservableGauge
hotWater, alerts, wifi, lastUpdated metric.Int64ObservableGauge hotWater, alerts, wifi, lastUpdated metric.Int64ObservableGauge
mode metric.Int64ObservableGauge
info metric.Int64ObservableGauge info metric.Int64ObservableGauge
} }
func (g *liveGauges) observe(o metric.Observer, d *rheemcloud.Device) { func (g *liveGauges) observe(o metric.Observer, d *econetclient.Device) {
set := metric.WithAttributes(deviceAttrs(d)...) set := metric.WithAttributes(deviceAttrs(d)...)
min, max := d.SetpointLimits() o.ObserveInt64(g.setpoint, int64(d.Setpoint), set)
o.ObserveInt64(g.setpoint, int64(d.Setpoint()), set) o.ObserveInt64(g.setpointMin, int64(d.SetpointMin), set)
o.ObserveInt64(g.setpointMin, int64(min), set) o.ObserveInt64(g.setpointMax, int64(d.SetpointMax), set)
o.ObserveInt64(g.setpointMax, int64(max), set) o.ObserveInt64(g.connected, b2i(d.Connected), set)
o.ObserveInt64(g.connected, b2i(d.Connected()), set) runSet := metric.WithAttributes(append(deviceAttrs(d), attribute.String("econet.running_state", d.RunningState))...)
o.ObserveInt64(g.running, b2i(d.Running()), set) o.ObserveInt64(g.running, b2i(d.Running), runSet)
o.ObserveInt64(g.enabled, b2i(d.Enabled()), set) o.ObserveInt64(g.enabled, b2i(d.Enabled), set)
o.ObserveInt64(g.away, b2i(d.Away()), set) o.ObserveInt64(g.away, b2i(d.Away), set)
o.ObserveInt64(g.hotWater, int64(d.HotWaterAvailability()), set) o.ObserveInt64(g.hotWater, int64(d.HotWaterAvailability), set)
o.ObserveInt64(g.alerts, int64(d.AlertCount()), set) o.ObserveInt64(g.alerts, int64(d.AlertCount), set)
o.ObserveInt64(g.wifi, int64(d.WiFiSignal()), set) o.ObserveInt64(g.wifi, int64(d.WiFiSignal), set)
// LastUpdated is zero until the first MQTT update lands; skip it then
// rather than emit a nonsensical negative epoch.
if !d.LastUpdated.IsZero() { if !d.LastUpdated.IsZero() {
o.ObserveInt64(g.lastUpdated, d.LastUpdated.Unix(), set) o.ObserveInt64(g.lastUpdated, d.LastUpdated.Unix(), set)
} }
for _, m := range econetclient.KnownModes {
modeSet := metric.WithAttributes(append(deviceAttrs(d), attribute.String("econet.mode", m))...)
o.ObserveInt64(g.mode, b2i(m == d.Mode), modeSet)
}
o.ObserveInt64(g.info, 1, metric.WithAttributes(infoAttrs(d)...)) o.ObserveInt64(g.info, 1, metric.WithAttributes(infoAttrs(d)...))
} }
@@ -93,34 +96,34 @@ type usageGauges struct {
energy, cost, water metric.Float64ObservableGauge energy, cost, water metric.Float64ObservableGauge
} }
func (g *usageGauges) observe(o metric.Observer, serial string, u usage, costPerKWH float64) { func (g *usageGauges) observe(o metric.Observer, d *econetclient.Device, costPerKWH float64) {
set := metric.WithAttributes(attribute.String("econet.serial", serial)) serial := attribute.String("econet.serial", d.SerialNumber)
o.ObserveFloat64(g.energy, u.energyKWH, metric.WithAttributes( o.ObserveFloat64(g.energy, d.EnergyKWH, metric.WithAttributes(
attribute.String("econet.serial", serial), serial,
attribute.String("econet.energy_type", u.energyType), attribute.String("econet.energy_type", d.EnergyType),
)) ))
o.ObserveFloat64(g.water, u.waterGallons, set) o.ObserveFloat64(g.water, d.WaterGallons, metric.WithAttributes(serial))
if u.energyType == "KWH" { if d.EnergyType == "KWH" {
o.ObserveFloat64(g.cost, u.energyKWH*costPerKWH, set) o.ObserveFloat64(g.cost, d.EnergyKWH*costPerKWH, metric.WithAttributes(serial))
} }
} }
// deviceAttrs are low-cardinality identity attributes safe for every series. // deviceAttrs are low-cardinality identity attributes safe for every series.
// They follow OTEL semantic-convention style (namespaced, dotted keys) and // They follow OTEL semantic-convention style (namespaced, dotted keys) and
// deliberately exclude anything that changes over time (e.g. timestamps). // deliberately exclude anything that changes over time (e.g. timestamps).
func deviceAttrs(d *rheemcloud.Device) []attribute.KeyValue { func deviceAttrs(d *econetclient.Device) []attribute.KeyValue {
return []attribute.KeyValue{ return []attribute.KeyValue{
attribute.String("econet.serial", d.SerialNumber()), attribute.String("econet.serial", d.SerialNumber),
attribute.String("econet.device_id", d.DeviceID()), attribute.String("econet.device_id", d.DeviceID),
attribute.String("econet.friendly_name", d.FriendlyName()), attribute.String("econet.friendly_name", d.FriendlyName),
} }
} }
func infoAttrs(d *rheemcloud.Device) []attribute.KeyValue { func infoAttrs(d *econetclient.Device) []attribute.KeyValue {
return append(deviceAttrs(d), return append(deviceAttrs(d),
attribute.String("econet.type", d.Type().String()), attribute.String("econet.type", d.Type),
attribute.String("econet.generic_type", d.GenericType()), attribute.String("econet.generic_type", d.GenericType),
attribute.String("econet.mode", d.Mode().String()), attribute.String("econet.mode", d.Mode),
) )
} }
+10 -89
View File
@@ -1,123 +1,44 @@
// Package econetmetrics registers OTEL observable gauges for Rheem EcoNet // Package econetmetrics registers OTEL observable gauges for Rheem EcoNet
// device state and periodically polls energy/water usage history so the // device state. Values are read at scrape time from the econetclient snapshot,
// values are available at Prometheus scrape time. // which a background poller keeps fresh over REST.
package econetmetrics package econetmetrics
import ( import (
"context" "context"
"sync"
"time"
rheemcloud "github.com/kevinburke/rheemcloud-go"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/metric"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/otel" "gitea.libretechconsulting.com/rmcguire/go-app/pkg/otel"
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/config" "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/config"
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetclient"
) )
// usage holds the most recent polled energy/water totals for a device.
type usage struct {
energyKWH float64
energyType string
waterGallons float64
}
type Collector struct { type Collector struct {
ctx context.Context ctx context.Context
cfg *config.ServiceConfig cfg *config.ServiceConfig
log *zerolog.Logger log *zerolog.Logger
client *rheemcloud.Client client *econetclient.Client
meter metric.Meter meter metric.Meter
mu sync.RWMutex
usage map[string]usage // keyed by serial number
stop chan struct{}
} }
func NewCollector(ctx context.Context, cfg *config.ServiceConfig, client *rheemcloud.Client) *Collector { func NewCollector(ctx context.Context, cfg *config.ServiceConfig, client *econetclient.Client) *Collector {
return &Collector{ return &Collector{
ctx: ctx, ctx: ctx,
cfg: cfg, cfg: cfg,
log: zerolog.Ctx(ctx), log: zerolog.Ctx(ctx),
client: client, client: client,
meter: otel.GetMeter(ctx, "econet"), meter: otel.GetMeter(ctx, "econet"),
usage: make(map[string]usage),
stop: make(chan struct{}),
} }
} }
// Start registers the gauge callbacks and launches the usage poller and // RegisterGauges registers the observable gauge callbacks. It is safe to call
// event drain goroutines. The rheemcloud client keeps live state fresh over // before the first refresh: the callbacks observe nothing while no devices are
// MQTT; draining its event channel keeps the cap-64 buffer from filling. // loaded, so /metrics comes up immediately.
func (c *Collector) Start() error { func (c *Collector) RegisterGauges() error {
if err := c.registerLive(); err != nil { if err := c.registerLive(); err != nil {
return err return err
} }
if err := c.registerUsage(); err != nil { return c.registerUsage()
return err
}
go c.drainEvents()
go c.pollUsage()
return nil
}
func (c *Collector) Stop() { close(c.stop) }
func (c *Collector) drainEvents() {
events := c.client.Subscribe()
for {
select {
case <-c.stop:
return
case <-c.ctx.Done():
return
case ev, ok := <-events:
if !ok {
return
}
c.log.Debug().Str("event", ev.Kind.String()).Msg("econet event")
}
}
}
func (c *Collector) pollUsage() {
c.refreshUsage()
t := time.NewTicker(c.cfg.GetUsageInterval())
defer t.Stop()
for {
select {
case <-c.stop:
return
case <-c.ctx.Done():
return
case <-t.C:
c.refreshUsage()
}
}
}
// refreshUsage polls each device's energy/water usage for the current day
// and caches the totals for the usage gauge callback to read.
func (c *Collector) refreshUsage() {
now := time.Now()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
for serial, d := range c.client.Devices() {
u := usage{}
if e, err := c.client.EnergyUsage(c.ctx, d, start, now); err == nil {
u.energyKWH, u.energyType = e.Total, e.EnergyType
} else {
c.log.Debug().Err(err).Str("serial", serial).Msg("energy usage poll failed")
}
if w, err := c.client.WaterUsage(c.ctx, d, start, now); err == nil {
u.waterGallons = w.Total
} else {
c.log.Debug().Err(err).Str("serial", serial).Msg("water usage poll failed")
}
c.mu.Lock()
c.usage[serial] = u
c.mu.Unlock()
}
} }
+36
View File
@@ -30,6 +30,22 @@ message Device {
google.protobuf.Timestamp last_updated = 18; google.protobuf.Timestamp last_updated = 18;
} }
// Mode is a settable operating mode for a water heater. The values mirror the
// kebab-case slugs reported in Device.mode. Not every device supports every
// mode; the supported set is device-specific (derived from the unit's reported
// mode list), so SetMode validates against the target device.
enum Mode {
MODE_UNSPECIFIED = 0;
MODE_OFF = 1;
MODE_ELECTRIC = 2;
MODE_ENERGY_SAVING = 3;
MODE_HEAT_PUMP = 4;
MODE_HIGH_DEMAND = 5;
MODE_GAS = 6;
MODE_PERFORMANCE = 7;
MODE_VACATION = 8;
}
message ListDevicesRequest {} message ListDevicesRequest {}
message ListDevicesResponse { message ListDevicesResponse {
@@ -44,6 +60,20 @@ message GetDeviceResponse {
Device device = 1; Device device = 1;
} }
message SetModeRequest {
string serial_number = 1 [(buf.validate.field).string.min_len = 1];
Mode mode = 2 [(buf.validate.field).enum = {
defined_only: true
not_in: [0]
}];
}
message SetModeResponse {
// Last-known device state. The mode change is applied asynchronously by the
// Rheem cloud, so this reflects the requested mode only after a later poll.
Device device = 1;
}
// EconetService exposes read-only access to Rheem EcoNet device state. // EconetService exposes read-only access to Rheem EcoNet device state.
service EconetService { service EconetService {
rpc ListDevices(ListDevicesRequest) returns (ListDevicesResponse) { rpc ListDevices(ListDevicesRequest) returns (ListDevicesResponse) {
@@ -52,4 +82,10 @@ service EconetService {
rpc GetDevice(GetDeviceRequest) returns (GetDeviceResponse) { rpc GetDevice(GetDeviceRequest) returns (GetDeviceResponse) {
option (google.api.http) = {get: "/v1alpha1/devices/{serial_number}"}; option (google.api.http) = {get: "/v1alpha1/devices/{serial_number}"};
} }
rpc SetMode(SetModeRequest) returns (SetModeResponse) {
option (google.api.http) = {
post: "/v1alpha1/devices/{serial_number}/mode"
body: "*"
};
}
} }