20 Commits

Author SHA1 Message Date
rmcguire 516eccec0c update CHANGELOG
Build and Publish / check-chart (push) Successful in 12s
Build and Publish / helm-release (push) Has been skipped
Build and Publish / go-binaries (push) Successful in 2m5s
Build and Publish / container-images (push) Successful in 2m14s
2026-07-16 16:34:32 -04:00
rmcguire df01e060e4 add ui, new config opts 2026-07-16 16:32:44 -04:00
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
48 changed files with 4215 additions and 173 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:
+61 -4
View File
@@ -16,16 +16,55 @@ Exporter for Rheem EcoNet / Rheemcloud water heaters, built on the
HTTP/gRPC server startup. The client is stateless REST, so `ShutdownFunc` is HTTP/gRPC server startup. The client is stateless REST, so `ShutdownFunc` is
effectively a no-op (the poll goroutine stops on ctx cancel). effectively a no-op (the poll goroutine stops on ctx cancel).
- `pkg/econet/econetclient/` — a minimal EcoNet REST client (ClearBlade - `pkg/econet/econetclient/` — a minimal EcoNet REST client (ClearBlade
backend). `client.go` does the 3 REST calls (auth → `getUserDataForApp` backend). `client.go` does the read REST calls (auth → `getUserDataForApp`
`dynamicAction` usage) and caches a snapshot under an `RWMutex`; `device.go` `dynamicAction` usage) and caches a snapshot under an `RWMutex`; `device.go`
decodes the `@...` datapoints into a flat `Device` struct (ported from decodes the `@...` datapoints into a flat `Device` struct (ported from
kevinburke/rheemcloud-go, minus MQTT). `Devices()` is empty until the first kevinburke/rheemcloud-go, minus MQTT). `Devices()` is empty until the first
`Refresh` succeeds — consumers just see an empty list (no nil-guarding). `Refresh` succeeds — consumers just see an empty list (no nil-guarding).
**No MQTT**, so `WiFiSignal` and `Running`/`RunningState` stay zero. **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
paths don't), hence `SetMode` re-checks the enum itself.
- `pkg/econet/econetmcpgen/`**active** MCP server at `/api/mcp`. Registers
the tools generated by protoc-gen-go-mcp (`api/econet/v1alpha1/v1alpha1mcp/`)
against our go-sdk `*mcp.Server` via the `runtime/gosdk` adapter, forwarding
in-process to the gRPC server. Exposes the same package API as `econetmcp`.
- `pkg/econet/econetmcp/` — the older hand-written MCP server; a single
`list_water_heaters` tool delegating to the gRPC server. Kept in place as a
fallback. **Switch between the two** with the aliased `econetmcp` import in
`pkg/econet/econet.go` (one line — both packages export identical symbols).
- `pkg/econet/econetui/` — admin UI served at **`/`** (the root; the HTTP
server is not "under `/api`" — only the grpc-gateway is, via
`grpcGatewayPath`). Gated by `enableWebUI` (default true). Server-rendered
`html/template` + embedded assets (`embed.FS`: Pico.css classless, HTMX,
per-`generic_type` SVG icons) with a dark-mode toggle (persisted in
`localStorage`). Reads devices and writes mode through the in-process gRPC
server, same as the MCP packages. Routes: `GET /` (page), `GET /ui/devices`
(HTMX poll partial, every 30s), `POST /ui/devices/{serial}/mode` (returns a
re-rendered card), `GET /ui/devices/{serial}/debug` (raw device JSON for the
🐛 modal), `GET /static/`. A **fill-level meter** visualizes hot-water
availability. `iconFor` matches `@TYPE` loosely (substring, case/spacing
insensitive) since real values vary (e.g. `heatpumpWaterHeaterGen5`).
Template funcs (`fillClass`/`fillWidth`) take **int32** to match proto fields
`html/template` is strict about arg types.
**Pending state** for a mode change is best-effort: `pending.go` holds a
TTL-bounded (`pendingTTL`, 90s) desired-mode hint, shown as an "Updating…"
badge and cleared once the reported mode converges; a full page load ignores
it (fresh reported state), the poll honors it. **Auth is left to the edge**
(oauth2-proxy) — the UI/write live at `/` and `/ui/`; keep `/health` and
`/metrics` on skip-auth so probes/Prometheus keep working.
- `pkg/econet/econetmetrics/` — OTEL observable gauges (read the client - `pkg/econet/econetmetrics/` — OTEL observable gauges (read the client
snapshot at scrape time; no poller of its own). snapshot at scrape time; no poller of its own).
@@ -41,6 +80,14 @@ 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`.
- **The `default:` tag is NOT applied at runtime for custom `ServiceConfig`
fields** — `LoadEnv` uses plain `env.Parse` (no `DefaultValueTagName`), so
`default:` only feeds the JSON schema. A bool that must default to true
therefore can't be a plain `bool` (zero value = false); `enableWebUI` is a
`*bool` where nil ⇒ enabled (read via `WebUIEnabled()`). `disableWaterUsage`
is a plain `bool` because its desired default (poll water usage) is the zero
value — set it true to skip the wasted water-usage REST call on units that
don't report it.
- `econetTLSInsecure` (env `ECONET_TLS_INSECURE`) skips TLS certificate - `econetTLSInsecure` (env `ECONET_TLS_INSECURE`) skips TLS certificate
verification when connecting to the EcoNet cloud API. It exists because the verification when connecting to the EcoNet cloud API. It exists because the
upstream host (cloudblade) serves a upstream host (cloudblade) serves a
@@ -68,6 +115,8 @@ 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 ./...`.
- `econetclient.Device` is a plain struct, so `deviceToProto` and the datapoint - `econetclient.Device` is a plain struct, so `deviceToProto` and the datapoint
@@ -82,3 +131,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.
+64
View File
@@ -4,6 +4,70 @@ All notable changes to econet-exporter are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [v0.5.0] - 2026-07-16
### Added
* **Admin UI** at `/` (`econetui`): a server-rendered dashboard of devices and
their status (connectivity, running state, setpoint, hot-water, alerts) with a
per-device mode dropdown that writes via the in-process gRPC server. HTMX for
interactivity (30s auto-refresh, card-level swaps), Pico.css + embedded SVG
icons per device type, a hot-water **fill-level meter**, a 🐛 debug button
that shows the raw device JSON, and a persisted **dark-mode toggle** — all
served from a single binary via `embed.FS`, responsive down to mobile. Mode
changes show a best-effort, TTL-bounded "Updating…" pending badge that
reconciles against the reported state (or falls back to a fresh fetch if it
never lands). Auth is intentionally left to the edge (e.g. oauth2-proxy);
`/health` and `/metrics` stay unauthenticated.
* `enableWebUI` config (env `ECONET_ENABLE_WEB_UI`, default **true**) to toggle
the admin UI, and `disableWaterUsage` (env `ECONET_DISABLE_WATER_USAGE`,
default false) to skip the per-device water-usage REST call on units that
don't report it.
* `MODE_ELECTRIC_GAS` added to the `Mode` enum — real devices report a combined
"Electric/Gas" mode that was previously decoded as unknown and dropped from
the settable-mode list.
* `supported_modes` on the `Device` proto message (the settable modes for that
unit), populated from the device's reported mode list and surfaced over gRPC,
REST, and MCP.
## [v0.4.0] - 2026-07-16
### Added
* **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 ## [v0.3.0] - 2026-07-05
### Changed ### Changed
+16
View File
@@ -58,6 +58,22 @@ Polled usage: `econet_energy_usage_kwh`, `econet_water_usage_gallons`,
> Note: `econet_wifi_signal_db` is only delivered over Rheem's MQTT push stream, > Note: `econet_wifi_signal_db` is only delivered over Rheem's MQTT push stream,
> which this exporter does not use — it stays at `0`. > 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.
+234 -25
View File
@@ -24,6 +24,80 @@ 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
Mode_MODE_ELECTRIC_GAS Mode = 9 // combined electric/gas backup mode (e.g. hybrid units)
)
// 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",
9: "MODE_ELECTRIC_GAS",
}
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,
"MODE_ELECTRIC_GAS": 9,
}
)
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 {
@@ -46,6 +120,7 @@ type Device struct {
AlertCount int32 `protobuf:"varint,16,opt,name=alert_count,json=alertCount,proto3" json:"alert_count,omitempty"` AlertCount int32 `protobuf:"varint,16,opt,name=alert_count,json=alertCount,proto3" json:"alert_count,omitempty"`
Away bool `protobuf:"varint,17,opt,name=away,proto3" json:"away,omitempty"` Away bool `protobuf:"varint,17,opt,name=away,proto3" json:"away,omitempty"`
LastUpdated *timestamppb.Timestamp `protobuf:"bytes,18,opt,name=last_updated,json=lastUpdated,proto3" json:"last_updated,omitempty"` LastUpdated *timestamppb.Timestamp `protobuf:"bytes,18,opt,name=last_updated,json=lastUpdated,proto3" json:"last_updated,omitempty"`
SupportedModes []Mode `protobuf:"varint,19,rep,packed,name=supported_modes,json=supportedModes,proto3,enum=econet.v1alpha1.Mode" json:"supported_modes,omitempty"` // modes this device can be set to (may be empty)
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -206,6 +281,13 @@ func (x *Device) GetLastUpdated() *timestamppb.Timestamp {
return nil return nil
} }
func (x *Device) GetSupportedModes() []Mode {
if x != nil {
return x.SupportedModes
}
return nil
}
type ListDevicesRequest struct { type ListDevicesRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
@@ -374,11 +456,109 @@ 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 = "" +
"\n" + "\n" +
"\x1ceconet/v1alpha1/econet.proto\x12\x0feconet.v1alpha1\x1a\x1bbuf/validate/validate.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xde\x04\n" + "\x1ceconet/v1alpha1/econet.proto\x12\x0feconet.v1alpha1\x1a\x1bbuf/validate/validate.proto\x1a\x1cgoogle/api/annotations.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x9e\x05\n" +
"\x06Device\x12#\n" + "\x06Device\x12#\n" +
"\rserial_number\x18\x01 \x01(\tR\fserialNumber\x12\x1b\n" + "\rserial_number\x18\x01 \x01(\tR\fserialNumber\x12\x1b\n" +
"\tdevice_id\x18\x02 \x01(\tR\bdeviceId\x12#\n" + "\tdevice_id\x18\x02 \x01(\tR\bdeviceId\x12#\n" +
@@ -400,17 +580,36 @@ const file_econet_v1alpha1_econet_proto_rawDesc = "" +
"\valert_count\x18\x10 \x01(\x05R\n" + "\valert_count\x18\x10 \x01(\x05R\n" +
"alertCount\x12\x12\n" + "alertCount\x12\x12\n" +
"\x04away\x18\x11 \x01(\bR\x04away\x12=\n" + "\x04away\x18\x11 \x01(\bR\x04away\x12=\n" +
"\flast_updated\x18\x12 \x01(\v2\x1a.google.protobuf.TimestampR\vlastUpdated\"\x14\n" + "\flast_updated\x18\x12 \x01(\v2\x1a.google.protobuf.TimestampR\vlastUpdated\x12>\n" +
"\x0fsupported_modes\x18\x13 \x03(\x0e2\x15.econet.v1alpha1.ModeR\x0esupportedModes\"\x14\n" +
"\x12ListDevicesRequest\"H\n" + "\x12ListDevicesRequest\"H\n" +
"\x13ListDevicesResponse\x121\n" + "\x13ListDevicesResponse\x121\n" +
"\adevices\x18\x01 \x03(\v2\x17.econet.v1alpha1.DeviceR\adevices\"@\n" + "\adevices\x18\x01 \x03(\v2\x17.econet.v1alpha1.DeviceR\adevices\"@\n" +
"\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*\xcd\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\b\x12\x15\n" +
"\x11MODE_ELECTRIC_GAS\x10\t2\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 +624,37 @@ 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 0, // 1: econet.v1alpha1.Device.supported_modes:type_name -> econet.v1alpha1.Mode
0, // 2: econet.v1alpha1.GetDeviceResponse.device:type_name -> econet.v1alpha1.Device 1, // 2: econet.v1alpha1.ListDevicesResponse.devices:type_name -> econet.v1alpha1.Device
1, // 3: econet.v1alpha1.EconetService.ListDevices:input_type -> econet.v1alpha1.ListDevicesRequest 1, // 3: econet.v1alpha1.GetDeviceResponse.device:type_name -> econet.v1alpha1.Device
3, // 4: econet.v1alpha1.EconetService.GetDevice:input_type -> econet.v1alpha1.GetDeviceRequest 0, // 4: econet.v1alpha1.SetModeRequest.mode:type_name -> econet.v1alpha1.Mode
2, // 5: econet.v1alpha1.EconetService.ListDevices:output_type -> econet.v1alpha1.ListDevicesResponse 1, // 5: econet.v1alpha1.SetModeResponse.device:type_name -> econet.v1alpha1.Device
4, // 6: econet.v1alpha1.EconetService.GetDevice:output_type -> econet.v1alpha1.GetDeviceResponse 2, // 6: econet.v1alpha1.EconetService.ListDevices:input_type -> econet.v1alpha1.ListDevicesRequest
5, // [5:7] is the sub-list for method output_type 4, // 7: econet.v1alpha1.EconetService.GetDevice:input_type -> econet.v1alpha1.GetDeviceRequest
3, // [3:5] is the sub-list for method input_type 6, // 8: econet.v1alpha1.EconetService.SetMode:input_type -> econet.v1alpha1.SetModeRequest
3, // [3:3] is the sub-list for extension type_name 3, // 9: econet.v1alpha1.EconetService.ListDevices:output_type -> econet.v1alpha1.ListDevicesResponse
3, // [3:3] is the sub-list for extension extendee 5, // 10: econet.v1alpha1.EconetService.GetDevice:output_type -> econet.v1alpha1.GetDeviceResponse
0, // [0:3] is the sub-list for field type_name 7, // 11: econet.v1alpha1.EconetService.SetMode:output_type -> econet.v1alpha1.SetModeResponse
9, // [9:12] is the sub-list for method output_type
6, // [6:9] is the sub-list for method input_type
6, // [6:6] is the sub-list for extension type_name
6, // [6:6] is the sub-list for extension extendee
0, // [0:6] 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 +667,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
View File
@@ -5,6 +5,7 @@
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
pollInterval: 1m # how often device state + daily usage are re-fetched over REST pollInterval: 1m # how often device state + daily usage are re-fetched over REST
enableWebUI: true # serve the admin UI at / (default true; set false to disable)
# go-app config # go-app config
name: econet-exporter name: econet-exporter
+79
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": {
@@ -167,6 +213,13 @@
"lastUpdated": { "lastUpdated": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
},
"supportedModes": {
"type": "array",
"items": {
"$ref": "#/definitions/v1alpha1Mode"
},
"title": "modes this device can be set to (may be empty)"
} }
}, },
"description": "Device is the live state of a single Rheem EcoNet device\n(typically a water heater) as reported by the Rheem cloud." "description": "Device is the live state of a single Rheem EcoNet device\n(typically a water heater) as reported by the Rheem cloud."
@@ -190,6 +243,32 @@
} }
} }
} }
},
"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",
"MODE_ELECTRIC_GAS"
],
"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.\n\n - MODE_ELECTRIC_GAS: combined electric/gas backup mode (e.g. hybrid units)"
},
"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."
}
}
} }
} }
} }
+11
View File
@@ -127,6 +127,10 @@
"default": 0.19, "default": 0.19,
"type": "number" "type": "number"
}, },
"disableWaterUsage": {
"default": false,
"type": "boolean"
},
"econetEmail": { "econetEmail": {
"type": "string" "type": "string"
}, },
@@ -137,6 +141,13 @@
"default": false, "default": false,
"type": "boolean" "type": "boolean"
}, },
"enableWebUI": {
"default": true,
"type": [
"null",
"boolean"
]
},
"environment": { "environment": {
"type": "string" "type": "string"
}, },
+1
View File
@@ -9,6 +9,7 @@ ECONET_EMAIL="you@example.com"
ECONET_PASSWORD="changeme" ECONET_PASSWORD="changeme"
ECONET_COST_PER_KWH=0.12 ## US dollars per kWh (0.12 = 12 cents), for econet_energy_cost_dollars ECONET_COST_PER_KWH=0.12 ## US dollars per kWh (0.12 = 12 cents), for econet_energy_cost_dollars
ECONET_USAGE_INTERVAL=5m ## how often to poll energy/water usage history ECONET_USAGE_INTERVAL=5m ## how often to poll energy/water usage history
ECONET_ENABLE_WEB_UI=true ## serve the admin UI at / (default true; set false to disable)
# App OTEL Config # App OTEL Config
APP_OTEL_STDOUT_ENABLED=true ## For testing only APP_OTEL_STDOUT_ENABLED=true ## For testing only
+5 -1
View File
@@ -1,12 +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/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
@@ -36,6 +38,7 @@ 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
@@ -55,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
+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=
@@ -65,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=
@@ -77,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=
+2 -2
View File
@@ -15,7 +15,7 @@ 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.6 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
@@ -26,5 +26,5 @@ 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 }}
+12
View File
@@ -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
+19
View File
@@ -34,6 +34,19 @@ type ServiceConfig struct {
// usage are re-fetched over REST. // usage are re-fetched over REST.
PollInterval time.Duration `yaml:"pollInterval" json:"pollInterval,omitempty" env:"ECONET_POLL_INTERVAL"` PollInterval time.Duration `yaml:"pollInterval" json:"pollInterval,omitempty" env:"ECONET_POLL_INTERVAL"`
// EnableWebUI toggles the admin UI served at /. It defaults to enabled:
// a pointer so an unset value (nil) is distinguishable from an explicit
// false, which is what makes the default actually true at runtime —
// LoadEnv's plain env.Parse does not apply the `default` tag (that tag
// only feeds the JSON schema). Use WebUIEnabled to read it.
EnableWebUI *bool `yaml:"enableWebUI" json:"enableWebUI,omitempty" env:"ECONET_ENABLE_WEB_UI" default:"true"`
// DisableWaterUsage skips the per-device water-usage REST call on each
// refresh. Handy for units that don't report water usage, where the call is
// wasted work and just produces debug-log noise. Zero value (false) keeps
// water usage enabled, so unlike EnableWebUI this needs no pointer.
DisableWaterUsage bool `yaml:"disableWaterUsage" json:"disableWaterUsage,omitempty" env:"ECONET_DISABLE_WATER_USAGE" default:"false"`
// 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
// a complete configuration json schema // a complete configuration json schema
@@ -51,6 +64,12 @@ func (c *ServiceConfig) LoadEnv() error {
return env.Parse(c) return env.Parse(c)
} }
// WebUIEnabled reports whether the admin UI should be served. Unset defaults to
// true; set enableWebUI: false (or ECONET_ENABLE_WEB_UI=false) to disable it.
func (c *ServiceConfig) WebUIEnabled() bool {
return c.EnableWebUI == nil || *c.EnableWebUI
}
// GetPollInterval returns the configured poll interval or the default. // GetPollInterval returns the configured poll interval or the default.
func (c *ServiceConfig) GetPollInterval() time.Duration { func (c *ServiceConfig) GetPollInterval() time.Duration {
if c.PollInterval <= 0 { if c.PollInterval <= 0 {
+24
View File
@@ -0,0 +1,24 @@
package config
import "testing"
func TestWebUIEnabledDefaultsTrue(t *testing.T) {
tru, fls := true, false
cases := []struct {
name string
in *bool
want bool
}{
{"unset defaults to enabled", nil, true},
{"explicit true", &tru, true},
{"explicit false", &fls, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := &ServiceConfig{EnableWebUI: tc.in}
if got := c.WebUIEnabled(); got != tc.want {
t.Errorf("WebUIEnabled() = %v, want %v", got, tc.want)
}
})
}
}
+15 -2
View File
@@ -16,8 +16,13 @@ import (
"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/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/econet/econetui"
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/service" "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/service"
) )
@@ -28,6 +33,7 @@ type EconetService struct {
client *econetclient.Client client *econetclient.Client
grpc *econetgrpc.EconetGRPCServer grpc *econetgrpc.EconetGRPCServer
mcp *econetmcp.EconetMCPServer mcp *econetmcp.EconetMCPServer
ui *econetui.EconetUIServer
metrics *econetmetrics.Collector metrics *econetmetrics.Collector
} }
@@ -44,6 +50,9 @@ func (e *EconetService) Init(ctx context.Context, cfg *config.ServiceConfig) (se
e.client = econetclient.New(cfg, e.log) e.client = econetclient.New(cfg, e.log)
e.grpc = econetgrpc.NewEconetGRPCServer(ctx, cfg, e.client) e.grpc = econetgrpc.NewEconetGRPCServer(ctx, cfg, e.client)
e.mcp = econetmcp.NewEconetMCPServer(ctx, cfg, e.grpc) e.mcp = econetmcp.NewEconetMCPServer(ctx, cfg, e.grpc)
if cfg.WebUIEnabled() {
e.ui = econetui.NewEconetUIServer(ctx, cfg, e.grpc)
}
e.metrics = econetmetrics.NewCollector(ctx, cfg, e.client) e.metrics = econetmetrics.NewCollector(ctx, cfg, e.client)
if err := e.metrics.RegisterGauges(); err != nil { if err := e.metrics.RegisterGauges(); err != nil {
return nil, err return nil, err
@@ -96,9 +105,13 @@ func (e *EconetService) GetGRPC() *optsgrpc.AppGRPC {
} }
func (e *EconetService) GetHTTP() *optshttp.AppHTTP { func (e *EconetService) GetHTTP() *optshttp.AppHTTP {
handlers := e.mcp.GetHandlers()
if e.ui != nil {
handlers = append(handlers, e.ui.GetHandlers()...)
}
return &optshttp.AppHTTP{ return &optshttp.AppHTTP{
Ctx: e.ctx, Ctx: e.ctx,
Handlers: e.mcp.GetHandlers(), Handlers: handlers,
HealthChecks: e.healthChecks(), HealthChecks: e.healthChecks(),
} }
} }
+78
View File
@@ -37,6 +37,7 @@ type Client struct {
email string email string
password string password string
costPerKWH float64 costPerKWH float64
disableWaterUsage bool
http *http.Client http *http.Client
log *zerolog.Logger log *zerolog.Logger
@@ -55,6 +56,7 @@ func New(cfg *config.ServiceConfig, log *zerolog.Logger) *Client {
email: cfg.EconetEmail, email: cfg.EconetEmail,
password: cfg.EconetPassword, password: cfg.EconetPassword,
costPerKWH: cfg.CostPerKWH, costPerKWH: cfg.CostPerKWH,
disableWaterUsage: cfg.DisableWaterUsage,
http: &http.Client{Transport: transport, Timeout: 15 * time.Second}, http: &http.Client{Transport: transport, Timeout: 15 * time.Second},
log: log, log: log,
devices: map[string]*Device{}, devices: map[string]*Device{},
@@ -100,6 +102,7 @@ func (c *Client) Refresh(ctx context.Context) error {
devices := make(map[string]*Device, len(equipment)) devices := make(map[string]*Device, len(equipment))
for _, raw := range equipment { for _, raw := range equipment {
d := newDevice(raw, now) d := newDevice(raw, now)
c.traceRawEquipment(d.SerialNumber, raw)
if d.SerialNumber == "" { if d.SerialNumber == "" {
continue continue
} }
@@ -113,6 +116,67 @@ func (c *Client) Refresh(ctx context.Context) error {
return nil 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
}
// traceRawEquipment logs one device's undecoded equipment block so the full set
// of "@..." datapoints (including fields we don't decode, e.g. any image/model)
// can be inspected. The level guard keeps us from marshaling unless trace is on.
func (c *Client) traceRawEquipment(serial string, raw map[string]json.RawMessage) {
if c.log.GetLevel() > zerolog.TraceLevel {
return
}
b, err := json.Marshal(raw)
if err != nil {
return
}
c.log.Trace().Str("serial", serial).RawJSON("equipment", b).Msg("econet: raw equipment payload")
}
func (c *Client) clearToken() { func (c *Client) clearToken() {
c.mu.Lock() c.mu.Lock()
c.token = "" c.token = ""
@@ -187,6 +251,9 @@ func (c *Client) fetchUsage(ctx context.Context, d *Device) {
} else { } else {
c.log.Debug().Err(err).Str("serial", d.SerialNumber).Msg("energy usage poll failed") c.log.Debug().Err(err).Str("serial", d.SerialNumber).Msg("energy usage poll failed")
} }
if c.disableWaterUsage {
return
}
if w, _, err := c.usage(ctx, d, "waterUsage"); err == nil { if w, _, err := c.usage(ctx, d, "waterUsage"); err == nil {
d.WaterGallons = w d.WaterGallons = w
} else { } else {
@@ -274,6 +341,11 @@ func (c *Client) do(ctx context.Context, path, token string, body, out any) erro
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, bytes.TrimSpace(raw)) 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) return json.Unmarshal(raw, out)
} }
@@ -285,6 +357,12 @@ func (c *Client) userToken() string {
// --- usage helpers ------------------------------------------------------- // --- 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, // dayWindow returns ISO-8601 start/end strings covering the current local day,
// matching pyeconet's formats: energy includes the timezone offset, water does // matching pyeconet's formats: energy includes the timezone offset, water does
// not (the upstream parser is picky). // not (the upstream parser is picky).
+37 -1
View File
@@ -26,6 +26,11 @@ type Device struct {
Running bool // true when actively heating (decoded from @RUNNING over REST) Running bool // true when actively heating (decoded from @RUNNING over REST)
RunningState string // normalized @RUNNING label: "compressor-running" | "element-running" | "idle" | ... 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 Setpoint int
SetpointMin int SetpointMin int
SetpointMax int SetpointMax int
@@ -40,6 +45,10 @@ type Device struct {
EnergyKWH float64 EnergyKWH float64
EnergyType string // "KWH" or "KBTU" EnergyType string // "KWH" or "KBTU"
WaterGallons float64 WaterGallons float64
// Raw is the undecoded equipment payload as received, kept for the web UI's
// debug view (and to surface any fields we don't decode).
Raw json.RawMessage
} }
// newDevice decodes an equipment block (the raw JSON map for one unit) into // newDevice decodes an equipment block (the raw JSON map for one unit) into
@@ -62,9 +71,34 @@ func newDevice(info map[string]json.RawMessage, now time.Time) *Device {
d.FriendlyName = friendlyName(info, d.DeviceID) d.FriendlyName = friendlyName(info, d.DeviceID)
d.Setpoint, d.SetpointMin, d.SetpointMax = setpoint(info["@SETPOINT"]) d.Setpoint, d.SetpointMin, d.SetpointMax = setpoint(info["@SETPOINT"])
d.Running, d.RunningState = running(info) d.Running, d.RunningState = running(info)
d.modeEnumText = modeLabels(info)
if b, err := json.Marshal(info); err == nil {
d.Raw = b
}
return d 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 ------------------------------------------------------ // --- field decoders ------------------------------------------------------
func connected(info map[string]json.RawMessage) bool { func connected(info map[string]json.RawMessage) bool {
@@ -201,6 +235,8 @@ func modeFromEnumText(s string) string {
return "heat-pump" return "heat-pump"
case "HIGH_DEMAND": case "HIGH_DEMAND":
return "high-demand" return "high-demand"
case "ELECTRIC_GAS":
return "electric-gas"
case "GAS": case "GAS":
return "gas" return "gas"
case "PERFORMANCE": case "PERFORMANCE":
@@ -217,7 +253,7 @@ func modeFromEnumText(s string) string {
// series regardless of which one is currently active. // series regardless of which one is currently active.
var KnownModes = []string{ var KnownModes = []string{
"off", "electric", "energy-saving", "heat-pump", "off", "electric", "energy-saving", "heat-pump",
"high-demand", "gas", "performance", "vacation", "unknown", "high-demand", "electric-gas", "gas", "performance", "vacation", "unknown",
} }
// running reports whether the unit is actively heating and a normalized label // running reports whether the unit is actively heating and a normalized label
+66
View File
@@ -0,0 +1,66 @@
package econetclient
import (
"encoding/json"
"reflect"
"testing"
"time"
)
// sampleGen5 is a trimmed but faithful slice of a real getUserDataForApp
// equipment block for a Rheem Gen5 heat-pump water heater (see the values in
// the field decoders it exercises: @TYPE, @MODE enumText incl. "Electric/Gas",
// @HOTWATER "_v2" icon, @SETPOINT limits, @ENABLED, @NAME).
const sampleGen5 = `{
"@TYPE":"heatpumpWaterHeaterGen5",
"@CONNECTED":true,
"@ENABLED":{"constraints":{"enumText":["Disabled","Enabled "],"lowerLimit":0,"upperLimit":1},"status":"Enabled ","value":1},
"@MODE":{"constraints":{"enumText":["Off ","Energy Saver ","Heat Pump ","High Demand ","Electric/Gas ","Vacation "],"lowerLimit":0,"upperLimit":5},"status":"Energy Saver ","value":1},
"@HOTWATER":"ic_tank_hundread_percent_v2.png",
"@SETPOINT":{"constraints":{"lowerLimit":110,"upperLimit":140},"value":135},
"@NAME":{"constraints":{"stringLength":64},"value":"Heat Pump Water Heater"},
"@RUNNING":"",
"@ALERTCOUNT":0,
"device_name":"3224625639131449",
"device_type":"WH",
"serial_number":"04-17-1a-0d-22-11-25-c0-01"
}`
func TestNewDeviceGen5(t *testing.T) {
var info map[string]json.RawMessage
if err := json.Unmarshal([]byte(sampleGen5), &info); err != nil {
t.Fatalf("bad sample: %v", err)
}
d := newDevice(info, time.Now())
if d.SerialNumber != "04-17-1a-0d-22-11-25-c0-01" {
t.Errorf("serial = %q", d.SerialNumber)
}
if d.GenericType != "heatpumpWaterHeaterGen5" {
t.Errorf("genericType = %q", d.GenericType)
}
if d.FriendlyName != "Heat Pump Water Heater" {
t.Errorf("friendlyName = %q", d.FriendlyName)
}
if !d.Enabled {
t.Errorf("enabled = false, want true")
}
if d.Mode != "energy-saving" {
t.Errorf("mode = %q, want energy-saving", d.Mode)
}
if d.Setpoint != 135 || d.SetpointMin != 110 || d.SetpointMax != 140 {
t.Errorf("setpoint = %d [%d,%d], want 135 [110,140]", d.Setpoint, d.SetpointMin, d.SetpointMax)
}
if d.HotWaterAvailability != 100 {
t.Errorf("hotWater = %d, want 100", d.HotWaterAvailability)
}
if len(d.Raw) == 0 {
t.Errorf("raw payload not retained")
}
// "Electric/Gas" must map to a real, settable mode (not dropped as unknown).
want := []string{"off", "energy-saving", "heat-pump", "high-demand", "electric-gas", "vacation"}
if got := d.SupportedModes(); !reflect.DeepEqual(got, want) {
t.Errorf("supportedModes = %v, want %v", got, want)
}
}
+13
View File
@@ -27,5 +27,18 @@ func deviceToProto(d *econetclient.Device) *pb.Device {
AlertCount: int32(d.AlertCount), AlertCount: int32(d.AlertCount),
Away: d.Away, Away: d.Away,
LastUpdated: timestamppb.New(d.LastUpdated), LastUpdated: timestamppb.New(d.LastUpdated),
SupportedModes: supportedModes(d.SupportedModes()),
} }
} }
// supportedModes maps the device's mode slugs to proto enums, dropping any that
// aren't settable (e.g. "unknown").
func supportedModes(slugs []string) []pb.Mode {
out := make([]pb.Mode, 0, len(slugs))
for _, slug := range slugs {
if m, ok := modeFromSlug(slug); ok {
out = append(out, m)
}
}
return out
}
+72
View File
@@ -70,3 +70,75 @@ 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
} }
// DeviceRawJSON returns the undecoded EcoNet equipment payload for a device,
// used by the web UI's debug view. ok is false if the serial is unknown or no
// raw payload was captured. It is a debug accessor, not part of the RPC surface.
func (s *EconetGRPCServer) DeviceRawJSON(serial string) ([]byte, bool) {
d := s.client.Device(serial)
if d == nil || len(d.Raw) == 0 {
return nil, false
}
return d.Raw, true
}
// 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",
pb.Mode_MODE_ELECTRIC_GAS: "electric-gas",
}
// ModeSlug returns the normalized slug for a settable Mode, or "" if unknown.
func ModeSlug(m pb.Mode) string { return modeSlugs[m] }
// modeFromSlug is the reverse of modeSlugs; ok is false for slugs with no
// settable enum (e.g. "unknown").
func modeFromSlug(slug string) (pb.Mode, bool) {
for m, s := range modeSlugs {
if s == slug {
return m, true
}
}
return pb.Mode_MODE_UNSPECIFIED, false
}
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{
{ {
+162
View File
@@ -0,0 +1,162 @@
package econetui
import (
"bytes"
"context"
"encoding/json"
"net/http"
"strings"
pb "gitea.libretechconsulting.com/rmcguire/econet-exporter/api/econet/v1alpha1"
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetgrpc"
)
// cardView is the template model for one device card.
type cardView struct {
Device *pb.Device
Icon string // static asset path for the device type
Modes []modeOption // settable modes for the dropdown
Pending string // pretty label of an in-flight mode change, if any
Error string // last mode-change error, if any
}
type modeOption struct {
Value string // proto enum name, e.g. MODE_HEAT_PUMP
Label string // display label, e.g. Heat Pump
Selected bool
}
type pageView struct {
Devices []cardView
}
func (s *EconetUIServer) handleIndex(w http.ResponseWriter, r *http.Request) {
// A full page load is a "fresh get": show reported state, ignore pending.
s.render(w, "layout", pageView{Devices: s.deviceViews(r.Context(), false)})
}
func (s *EconetUIServer) handleDevices(w http.ResponseWriter, r *http.Request) {
// The polling refresh honors pending so an in-flight change stays visible
// until it lands or its TTL lapses.
s.render(w, "devices", pageView{Devices: s.deviceViews(r.Context(), true)})
}
func (s *EconetUIServer) handleSetMode(w http.ResponseWriter, r *http.Request) {
serial := r.PathValue("serial")
modeName := r.FormValue("mode")
modeVal, ok := pb.Mode_value[modeName]
if !ok || pb.Mode(modeVal) == pb.Mode_MODE_UNSPECIFIED {
s.renderCard(w, r.Context(), serial, "invalid mode "+modeName)
return
}
_, err := s.econetGRPC.SetMode(r.Context(), &pb.SetModeRequest{
SerialNumber: serial,
Mode: pb.Mode(modeVal),
})
if err != nil {
s.log.Warn().Err(err).Str("serial", serial).Msg("ui: set mode failed")
s.renderCard(w, r.Context(), serial, err.Error())
return
}
s.pending.set(serial, econetgrpc.ModeSlug(pb.Mode(modeVal)))
s.renderCard(w, r.Context(), serial, "")
}
// handleDebug returns the raw EcoNet payload for a device, pretty-printed, for
// the bug-icon modal.
func (s *EconetUIServer) handleDebug(w http.ResponseWriter, r *http.Request) {
serial := r.PathValue("serial")
raw, ok := s.econetGRPC.DeviceRawJSON(serial)
if !ok {
s.render(w, "debug", "No raw payload captured for "+serial+" yet.")
return
}
var buf bytes.Buffer
if err := json.Indent(&buf, raw, "", " "); err != nil {
buf.Write(raw)
}
s.render(w, "debug", buf.String())
}
// deviceViews builds card views for all devices. When usePending is true, an
// in-flight mode change is shown as a pending badge (and cleared once the
// reported mode catches up).
func (s *EconetUIServer) deviceViews(ctx context.Context, usePending bool) []cardView {
resp, err := s.econetGRPC.ListDevices(ctx, &pb.ListDevicesRequest{})
if err != nil {
s.log.Error().Err(err).Msg("ui: list devices failed")
return nil
}
views := make([]cardView, 0, len(resp.GetDevices()))
for _, d := range resp.GetDevices() {
views = append(views, s.toCardView(d, usePending))
}
return views
}
// renderCard re-renders a single device's card, used for the HTMX swap after a
// mode change. A successful change is optimistic (pending badge shown).
func (s *EconetUIServer) renderCard(w http.ResponseWriter, ctx context.Context, serial, errMsg string) {
resp, err := s.econetGRPC.GetDevice(ctx, &pb.GetDeviceRequest{SerialNumber: serial})
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
cv := s.toCardView(resp.GetDevice(), true)
cv.Error = errMsg
s.render(w, "card", cv)
}
func (s *EconetUIServer) toCardView(d *pb.Device, usePending bool) cardView {
cv := cardView{Device: d, Icon: iconFor(d.GetGenericType())}
for _, m := range d.GetSupportedModes() {
slug := econetgrpc.ModeSlug(m)
cv.Modes = append(cv.Modes, modeOption{
Value: m.String(),
Label: prettyMode(slug),
Selected: slug == d.GetMode(),
})
}
if usePending {
if desired, ok := s.pending.get(d.GetSerialNumber()); ok {
if desired == d.GetMode() {
s.pending.clear(d.GetSerialNumber()) // reported caught up
} else {
cv.Pending = prettyMode(desired)
}
}
}
return cv
}
func (s *EconetUIServer) render(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.log.Error().Err(err).Str("template", name).Msg("ui: render failed")
}
}
// iconFor maps a device's generic type to an embedded SVG asset. It matches
// loosely (case/spacing/underscore-insensitive substrings) because the reported
// @TYPE varies in form across firmware (e.g. "heatpumpWaterHeater", "HeatPump",
// "Hybrid Electric"); unrecognized types fall back to a generic icon.
func iconFor(genericType string) string {
t := strings.NewReplacer(" ", "", "_", "", "-", "").Replace(strings.ToLower(genericType))
switch {
case strings.Contains(t, "heatpump"), strings.Contains(t, "hybrid"):
return "/static/icons/heatpump.svg"
case strings.Contains(t, "tankless"):
return "/static/icons/tankless.svg"
case strings.Contains(t, "gas"):
return "/static/icons/gas.svg"
case strings.Contains(t, "electric"):
return "/static/icons/electric.svg"
default:
return "/static/icons/unknown.svg"
}
}
+54
View File
@@ -0,0 +1,54 @@
package econetui
import (
"sync"
"time"
)
// pendingStore holds best-effort "desired mode" hints for the brief, async
// window between a SetMode publish and the Rheem cloud reporting the new mode.
// Entries expire after a TTL so a mode change that never takes hold quietly
// falls back to the reported state (a "fresh get") rather than showing a stuck
// pending badge forever.
type pendingStore struct {
mu sync.Mutex
ttl time.Duration
m map[string]pendingEntry
}
type pendingEntry struct {
desired string // desired mode slug
expiresAt time.Time
}
func newPendingStore(ttl time.Duration) *pendingStore {
return &pendingStore{ttl: ttl, m: make(map[string]pendingEntry)}
}
func (p *pendingStore) set(serial, desired string) {
p.mu.Lock()
defer p.mu.Unlock()
p.m[serial] = pendingEntry{desired: desired, expiresAt: time.Now().Add(p.ttl)}
}
// get returns the desired mode slug for a serial, or ("", false) if there is no
// pending change or it has expired (expired entries are dropped).
func (p *pendingStore) get(serial string) (string, bool) {
p.mu.Lock()
defer p.mu.Unlock()
e, ok := p.m[serial]
if !ok {
return "", false
}
if time.Now().After(e.expiresAt) {
delete(p.m, serial)
return "", false
}
return e.desired, true
}
func (p *pendingStore) clear(serial string) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.m, serial)
}
+117
View File
@@ -0,0 +1,117 @@
/*
Package econetui serves a small admin UI at / for viewing EcoNet devices and
changing their operating mode. It renders server-side HTML (html/template with
embedded assets) and uses HTMX for interactivity; device reads and mode writes
go through the in-process gRPC server, exactly like the MCP packages. Auth is
left to the edge (oauth2-proxy) — this package serves the human surface at /
and the mode-write at /ui/, leaving /health, /metrics, and /api untouched.
*/
package econetui
import (
"context"
"embed"
"fmt"
"html/template"
"io/fs"
"net/http"
"strings"
"time"
"gitea.libretechconsulting.com/rmcguire/go-app/pkg/srv/http/opts"
"github.com/rs/zerolog"
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/config"
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetgrpc"
)
//go:embed templates/*.html
var templatesFS embed.FS
//go:embed static
var staticFS embed.FS
// pendingTTL bounds how long an optimistic mode change is shown before the UI
// falls back to the reported state.
const pendingTTL = 90 * time.Second
type EconetUIServer struct {
ctx context.Context
cfg *config.ServiceConfig
log *zerolog.Logger
econetGRPC *econetgrpc.EconetGRPCServer
tmpl *template.Template
pending *pendingStore
}
func NewEconetUIServer(ctx context.Context, cfg *config.ServiceConfig,
grpc *econetgrpc.EconetGRPCServer,
) *EconetUIServer {
tmpl := template.Must(template.New("").Funcs(template.FuncMap{
"prettyMode": prettyMode,
"fillClass": fillClass,
"fillWidth": fillWidth,
}).ParseFS(templatesFS, "templates/*.html"))
return &EconetUIServer{
ctx: ctx,
cfg: cfg,
log: zerolog.Ctx(ctx),
econetGRPC: grpc,
tmpl: tmpl,
pending: newPendingStore(pendingTTL),
}
}
func (s *EconetUIServer) GetHandlers() []opts.HTTPHandler {
staticSub, _ := fs.Sub(staticFS, "static")
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", s.handleIndex)
mux.HandleFunc("GET /ui/devices", s.handleDevices)
mux.HandleFunc("GET /ui/devices/{serial}/debug", s.handleDebug)
mux.HandleFunc("POST /ui/devices/{serial}/mode", s.handleSetMode)
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
s.log.Debug().Msg("Econet admin UI ready at /")
return []opts.HTTPHandler{{Prefix: "/", Handler: mux}}
}
// prettyMode turns a mode slug ("heat-pump") into a display label ("Heat Pump").
func prettyMode(slug string) string {
switch slug {
case "":
return "Unknown"
case "electric-gas":
return "Electric/Gas"
}
words := strings.Split(slug, "-")
for i, w := range words {
if w != "" {
words[i] = strings.ToUpper(w[:1]) + w[1:]
}
}
return strings.Join(words, " ")
}
// fillClass buckets a hot-water fill percentage into a color class. It takes
// int32 to match the proto Device field (html/template is strict about types).
func fillClass(pct int32) string {
switch {
case pct >= 66:
return "fill-high"
case pct >= 33:
return "fill-mid"
default:
return "fill-low"
}
}
// fillWidth returns a safe CSS width for the fill bar.
func fillWidth(pct int32) template.CSS {
if pct < 0 {
pct = 0
}
return template.CSS(fmt.Sprintf("width:%d%%", pct))
}
+151
View File
@@ -0,0 +1,151 @@
/* Layout + components for the EcoNet admin UI. Colors come from Pico's CSS
custom properties so light and dark themes both work without overrides. */
body {
max-width: 1100px;
margin: 0 auto;
padding: 2.5rem 2rem;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 2.5rem;
}
.app-header hgroup { margin-bottom: 0; }
.theme-toggle {
flex: 0 0 auto;
width: 3rem;
height: 3rem;
padding: 0;
border-radius: 50%;
font-size: 1.25rem;
line-height: 1;
}
/* Comfortable, capped card width that wraps and centers, so a single device
sits in a tidy card surrounded by whitespace rather than stretching wide or
getting crammed. min(100%, 420px) keeps it fluid on narrow screens. */
.device-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 460px));
justify-content: center;
gap: 2rem;
}
.device-card {
margin: 0;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.card-head {
display: flex;
align-items: center;
gap: 1rem;
}
.device-icon {
flex: 0 0 auto;
width: 56px;
height: 56px;
color: var(--pico-primary);
}
.card-title { flex: 1 1 auto; min-width: 0; line-height: 1.3; }
.card-title strong { display: block; font-size: 1.2rem; }
.card-title small { color: var(--pico-muted-color); word-break: break-all; }
.bug-btn {
flex: 0 0 auto;
padding: 0.25rem 0.4rem;
background: none;
border: none;
border-radius: var(--pico-border-radius);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
opacity: 0.55;
transition: opacity 0.15s ease;
}
.bug-btn:hover { opacity: 1; }
.dot {
flex: 0 0 auto;
width: 0.8rem;
height: 0.8rem;
border-radius: 50%;
display: inline-block;
}
.dot-ok { background: var(--pico-color-green-500, #22c55e); }
.dot-off { background: var(--pico-color-red-500, #ef4444); }
.stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem 1.5rem;
}
.stats > div { display: flex; flex-direction: column; gap: 0.2rem; }
.stats span { color: var(--pico-muted-color); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; }
.stats b { font-size: 1.1rem; }
.fill { display: flex; flex-direction: column; gap: 0.45rem; }
.fill-head { display: flex; justify-content: space-between; align-items: baseline; }
.fill-head span { color: var(--pico-muted-color); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; }
.fill-meter {
height: 0.6rem;
border-radius: 999px;
overflow: hidden;
background: color-mix(in srgb, var(--pico-muted-color) 22%, transparent);
}
.fill-bar { height: 100%; border-radius: 999px; transition: width 0.4s ease; }
.fill-high { background: var(--pico-color-green-500, #22c55e); }
.fill-mid { background: var(--pico-color-amber-500, #f59e0b); }
.fill-low { background: var(--pico-color-red-500, #ef4444); }
.card-foot {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.mode-label { margin: 0; font-size: 0.85rem; color: var(--pico-muted-color); }
.mode-label select { margin-top: 0.35rem; margin-bottom: 0; }
.mode-fixed { color: var(--pico-muted-color); }
.badge {
align-self: flex-start;
padding: 0.2rem 0.55rem;
border-radius: var(--pico-border-radius);
font-size: 0.8rem;
}
.badge-pending {
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
}
.badge-error {
background: var(--pico-color-red-500, #ef4444);
color: #fff;
}
.empty-state { grid-column: 1 / -1; text-align: center; padding: 3rem 1rem; }
.debug-modal pre {
max-height: 65vh;
overflow: auto;
margin: 0;
}
.debug-modal code { white-space: pre; }
/* Mobile: tighten spacing; the grid already collapses to a single full-width
column via minmax(min(100%, 420px), …). */
@media (max-width: 480px) {
body { padding: 1.25rem 1rem; }
.app-header { margin-bottom: 1.5rem; }
.app-header h1 { font-size: 1.6rem; }
.device-grid { gap: 1.25rem; }
.device-card { padding: 1.5rem; gap: 1.25rem; }
.device-icon { width: 48px; height: 48px; }
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="14" y="8" width="20" height="32" rx="4"/>
<line x1="18" y1="40" x2="18" y2="43"/>
<line x1="30" y1="40" x2="30" y2="43"/>
<path d="M25 15l-5 7h4l-1 7 6-8h-4z" fill="currentColor" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 397 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="14" y="6" width="20" height="28" rx="4"/>
<line x1="18" y1="34" x2="18" y2="37"/>
<line x1="30" y1="34" x2="30" y2="37"/>
<path d="M24 38c-3 0-5 2-5 4.5S21 46 24 46s5-1.5 5-3.5c0-3-3-3.5-2-6.5-2 1-3 2.5-3 4.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 415 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="14" y="12" width="20" height="30" rx="4"/>
<line x1="18" y1="42" x2="18" y2="45"/>
<line x1="30" y1="42" x2="30" y2="45"/>
<circle cx="24" cy="9" r="6"/>
<path d="M24 5.5v7M20.5 9h7M21.5 6.5l5 5M26.5 6.5l-5 5"/>
</svg>

After

Width:  |  Height:  |  Size: 416 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="12" y="10" width="24" height="22" rx="3"/>
<line x1="18" y1="32" x2="18" y2="40"/>
<line x1="30" y1="32" x2="30" y2="40"/>
<line x1="17" y1="17" x2="31" y2="17"/>
<circle cx="24" cy="25" r="3"/>
</svg>

After

Width:  |  Height:  |  Size: 399 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="14" y="8" width="20" height="32" rx="4"/>
<line x1="18" y1="40" x2="18" y2="43"/>
<line x1="30" y1="40" x2="30" y2="43"/>
<path d="M21 20a3 3 0 1 1 4 2.8c-1 .5-1 1.2-1 2.2"/>
<line x1="24" y1="30" x2="24" y2="30.5"/>
</svg>

After

Width:  |  Height:  |  Size: 421 B

File diff suppressed because one or more lines are too long
+57
View File
@@ -0,0 +1,57 @@
{{define "card"}}
<article id="card-{{.Device.SerialNumber}}" class="device-card">
<div class="card-head">
<img class="device-icon" src="{{.Icon}}" alt="" width="56" height="56">
<div class="card-title">
<strong>{{.Device.FriendlyName}}</strong>
<small>{{.Device.SerialNumber}}</small>
</div>
<button class="bug-btn" title="Show raw device JSON" aria-label="Show raw device JSON"
hx-get="/ui/devices/{{.Device.SerialNumber}}/debug"
hx-target="#debug-modal-body"
hx-swap="innerHTML"
onclick="document.getElementById('debug-modal').showModal()">🐛</button>
<span class="dot {{if .Device.Connected}}dot-ok{{else}}dot-off{{end}}"
title="{{if .Device.Connected}}Connected{{else}}Disconnected{{end}}"></span>
</div>
<div class="stats">
<div><span>Status</span>
<b>{{if .Device.Running}}🔥 {{prettyMode .Device.RunningState}}{{else}}Idle{{end}}</b></div>
<div><span>Setpoint</span><b>{{.Device.Setpoint}}&deg;F</b></div>
<div><span>Alerts</span>
<b>{{if gt .Device.AlertCount 0}}⚠ {{.Device.AlertCount}}{{else}}0{{end}}</b></div>
</div>
{{if ge .Device.HotWaterAvailability 0}}
<div class="fill">
<div class="fill-head"><span>Hot water</span><b>{{.Device.HotWaterAvailability}}%</b></div>
<div class="fill-meter">
<div class="fill-bar {{fillClass .Device.HotWaterAvailability}}" style="{{fillWidth .Device.HotWaterAvailability}}"></div>
</div>
</div>
{{end}}
<div class="card-foot">
{{if .Modes}}
<label class="mode-label">
Mode
<select name="mode"
hx-post="/ui/devices/{{.Device.SerialNumber}}/mode"
hx-target="#card-{{.Device.SerialNumber}}"
hx-swap="outerHTML"
hx-trigger="change"
{{if .Pending}}disabled aria-busy="true"{{end}}>
{{range .Modes}}
<option value="{{.Value}}" {{if .Selected}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
</label>
{{if .Pending}}<span class="badge badge-pending" aria-busy="true">Updating → {{.Pending}}…</span>{{end}}
{{else}}
<small class="mode-fixed">Mode: {{prettyMode .Device.Mode}} (not settable)</small>
{{end}}
{{if .Error}}<span class="badge badge-error" role="alert">{{.Error}}</span>{{end}}
</div>
</article>
{{end}}
+1
View File
@@ -0,0 +1 @@
{{define "debug"}}{{.}}{{end}}
@@ -0,0 +1,10 @@
{{define "devices"}}
{{if .Devices}}
{{range .Devices}}{{template "card" .}}{{end}}
{{else}}
<article class="empty-state">
<p>No EcoNet devices loaded yet.</p>
<small>Device state populates after the first successful cloud poll.</small>
</article>
{{end}}
{{end}}
+57
View File
@@ -0,0 +1,57 @@
{{define "layout"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>EcoNet Admin</title>
<link rel="stylesheet" href="/static/pico.classless.min.css">
<link rel="stylesheet" href="/static/app.css">
<script src="/static/htmx.min.js" defer></script>
<script>
// Set the theme before first paint to avoid a flash.
(function () {
var saved = localStorage.getItem("theme");
var theme = saved || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.setAttribute("data-theme", theme);
})();
</script>
</head>
<body>
<header class="app-header">
<hgroup>
<h1>EcoNet Admin</h1>
<p>Rheem water heater status &amp; control</p>
</hgroup>
<button id="theme-toggle" class="secondary theme-toggle" aria-label="Toggle dark mode" onclick="toggleTheme()"></button>
</header>
<main>
<div id="devices" class="device-grid" hx-get="/ui/devices" hx-trigger="every 30s" hx-swap="innerHTML">
{{template "devices" .}}
</div>
</main>
<dialog id="debug-modal" class="debug-modal">
<article>
<header>
<button aria-label="Close" rel="prev" onclick="document.getElementById('debug-modal').close()"></button>
<strong>Raw device JSON</strong>
</header>
<pre><code id="debug-modal-body">Loading…</code></pre>
</article>
</dialog>
<script>
function themeIcon(t) { return t === "dark" ? "☀️" : "🌙"; } // sun / moon
function toggleTheme() {
var cur = document.documentElement.getAttribute("data-theme");
var next = cur === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("theme", next);
document.getElementById("theme-toggle").textContent = themeIcon(next);
}
document.getElementById("theme-toggle").textContent =
themeIcon(document.documentElement.getAttribute("data-theme"));
</script>
</body>
</html>{{end}}
+148
View File
@@ -0,0 +1,148 @@
package econetui
import (
"context"
"net/http/httptest"
"strings"
"testing"
pb "gitea.libretechconsulting.com/rmcguire/econet-exporter/api/econet/v1alpha1"
"gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/config"
)
func newTestServer(t *testing.T) *EconetUIServer {
t.Helper()
// grpc is nil: the render/view helpers under test never touch it.
return NewEconetUIServer(context.Background(), &config.ServiceConfig{}, nil)
}
func sampleDevice() *pb.Device {
return &pb.Device{
SerialNumber: "SN123",
FriendlyName: "Garage Water Heater",
GenericType: "heatpumpWaterHeater",
Connected: true,
Mode: "energy-saving",
Running: true,
RunningState: "compressor-running",
Setpoint: 120,
HotWaterAvailability: 66,
AlertCount: 1,
SupportedModes: []pb.Mode{
pb.Mode_MODE_HEAT_PUMP,
pb.Mode_MODE_ENERGY_SAVING,
pb.Mode_MODE_HIGH_DEMAND,
},
}
}
func TestCardRendersWithPending(t *testing.T) {
s := newTestServer(t)
d := sampleDevice()
// Simulate an in-flight change to a mode different from the reported one.
s.pending.set(d.SerialNumber, "heat-pump")
cv := s.toCardView(d, true)
if cv.Pending != "Heat Pump" {
t.Fatalf("pending = %q, want %q", cv.Pending, "Heat Pump")
}
if cv.Icon != "/static/icons/heatpump.svg" {
t.Fatalf("icon = %q", cv.Icon)
}
rec := httptest.NewRecorder()
s.render(rec, "card", cv)
body := rec.Body.String()
for _, want := range []string{
`id="card-SN123"`,
"Garage Water Heater",
`hx-post="/ui/devices/SN123/mode"`,
"badge-pending",
"Updating", // "Updating → Heat Pump…"
"Energy Saving",
"Heat Pump",
"High Demand",
"120&deg;F",
"66%",
"fill-meter", // fill-level graph
"fill-high", // 66% → high bucket (>=66)
} {
if !strings.Contains(body, want) {
t.Errorf("card body missing %q\n---\n%s", want, body)
}
}
}
func TestCardConvergenceClearsPending(t *testing.T) {
s := newTestServer(t)
d := sampleDevice()
d.Mode = "heat-pump" // reported now matches the desired
s.pending.set(d.SerialNumber, "heat-pump")
cv := s.toCardView(d, true)
if cv.Pending != "" {
t.Fatalf("pending should clear once reported catches up, got %q", cv.Pending)
}
if _, ok := s.pending.get(d.SerialNumber); ok {
t.Fatalf("pending entry should have been removed after convergence")
}
}
func TestDevicesEmptyState(t *testing.T) {
s := newTestServer(t)
rec := httptest.NewRecorder()
s.render(rec, "devices", pageView{})
if !strings.Contains(rec.Body.String(), "No EcoNet devices loaded yet") {
t.Errorf("empty state not rendered:\n%s", rec.Body.String())
}
}
func TestIconForLoose(t *testing.T) {
cases := map[string]string{
"heatpumpWaterHeater": "/static/icons/heatpump.svg",
"heatpumpWaterHeaterGen5": "/static/icons/heatpump.svg",
"HeatPump": "/static/icons/heatpump.svg",
"Hybrid Electric": "/static/icons/heatpump.svg",
"heat_pump_water_heater": "/static/icons/heatpump.svg",
"gasWaterHeater": "/static/icons/gas.svg",
"tanklessWaterHeater": "/static/icons/tankless.svg",
"electricWaterHeater": "/static/icons/electric.svg",
"somethingElse": "/static/icons/unknown.svg",
"": "/static/icons/unknown.svg",
}
for in, want := range cases {
if got := iconFor(in); got != want {
t.Errorf("iconFor(%q) = %q, want %q", in, got, want)
}
}
}
func TestDebugTemplateEscapes(t *testing.T) {
s := newTestServer(t)
rec := httptest.NewRecorder()
s.render(rec, "debug", `{"@NAME":"<b>x</b>"}`)
body := rec.Body.String()
if strings.Contains(body, "<b>") {
t.Errorf("debug output not escaped: %s", body)
}
if !strings.Contains(body, "&lt;b&gt;") {
t.Errorf("expected escaped JSON, got: %s", body)
}
}
func TestPrettyMode(t *testing.T) {
cases := map[string]string{
"heat-pump": "Heat Pump",
"energy-saving": "Energy Saving",
"off": "Off",
"": "Unknown",
}
for in, want := range cases {
if got := prettyMode(in); got != want {
t.Errorf("prettyMode(%q) = %q, want %q", in, got, want)
}
}
}
+38
View File
@@ -28,6 +28,24 @@ message Device {
int32 alert_count = 16; int32 alert_count = 16;
bool away = 17; bool away = 17;
google.protobuf.Timestamp last_updated = 18; google.protobuf.Timestamp last_updated = 18;
repeated Mode supported_modes = 19; // modes this device can be set to (may be empty)
}
// 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;
MODE_ELECTRIC_GAS = 9; // combined electric/gas backup mode (e.g. hybrid units)
} }
message ListDevicesRequest {} message ListDevicesRequest {}
@@ -44,6 +62,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 +84,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: "*"
};
}
} }