2 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
33 changed files with 1109 additions and 37 deletions
+28
View File
@@ -45,6 +45,26 @@ Exporter for Rheem EcoNet / Rheemcloud water heaters, built on the
`list_water_heaters` tool delegating to the gRPC server. Kept in place as a `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 fallback. **Switch between the two** with the aliased `econetmcp` import in
`pkg/econet/econet.go` (one line — both packages export identical symbols). `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).
@@ -60,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
+26
View File
@@ -6,6 +6,32 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [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 ## [v0.4.0] - 2026-07-16
### Added ### Added
+33 -19
View File
@@ -40,6 +40,7 @@ const (
Mode_MODE_GAS Mode = 6 Mode_MODE_GAS Mode = 6
Mode_MODE_PERFORMANCE Mode = 7 Mode_MODE_PERFORMANCE Mode = 7
Mode_MODE_VACATION Mode = 8 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. // Enum value maps for Mode.
@@ -54,6 +55,7 @@ var (
6: "MODE_GAS", 6: "MODE_GAS",
7: "MODE_PERFORMANCE", 7: "MODE_PERFORMANCE",
8: "MODE_VACATION", 8: "MODE_VACATION",
9: "MODE_ELECTRIC_GAS",
} }
Mode_value = map[string]int32{ Mode_value = map[string]int32{
"MODE_UNSPECIFIED": 0, "MODE_UNSPECIFIED": 0,
@@ -65,6 +67,7 @@ var (
"MODE_GAS": 6, "MODE_GAS": 6,
"MODE_PERFORMANCE": 7, "MODE_PERFORMANCE": 7,
"MODE_VACATION": 8, "MODE_VACATION": 8,
"MODE_ELECTRIC_GAS": 9,
} }
) )
@@ -117,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
} }
@@ -277,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
@@ -547,7 +558,7 @@ 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" +
@@ -569,7 +580,8 @@ 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" +
@@ -582,7 +594,7 @@ const file_econet_v1alpha1_econet_proto_rawDesc = "" +
"\x04mode\x18\x02 \x01(\x0e2\x15.econet.v1alpha1.ModeB\n" + "\x04mode\x18\x02 \x01(\x0e2\x15.econet.v1alpha1.ModeB\n" +
"\xbaH\a\x82\x01\x04\x10\x01 \x00R\x04mode\"B\n" + "\xbaH\a\x82\x01\x04\x10\x01 \x00R\x04mode\"B\n" +
"\x0fSetModeResponse\x12/\n" + "\x0fSetModeResponse\x12/\n" +
"\x06device\x18\x01 \x01(\v2\x17.econet.v1alpha1.DeviceR\x06device*\xb6\x01\n" + "\x06device\x18\x01 \x01(\v2\x17.econet.v1alpha1.DeviceR\x06device*\xcd\x01\n" +
"\x04Mode\x12\x14\n" + "\x04Mode\x12\x14\n" +
"\x10MODE_UNSPECIFIED\x10\x00\x12\f\n" + "\x10MODE_UNSPECIFIED\x10\x00\x12\f\n" +
"\bMODE_OFF\x10\x01\x12\x11\n" + "\bMODE_OFF\x10\x01\x12\x11\n" +
@@ -592,7 +604,8 @@ const file_econet_v1alpha1_econet_proto_rawDesc = "" +
"\x10MODE_HIGH_DEMAND\x10\x05\x12\f\n" + "\x10MODE_HIGH_DEMAND\x10\x05\x12\f\n" +
"\bMODE_GAS\x10\x06\x12\x14\n" + "\bMODE_GAS\x10\x06\x12\x14\n" +
"\x10MODE_PERFORMANCE\x10\a\x12\x11\n" + "\x10MODE_PERFORMANCE\x10\a\x12\x11\n" +
"\rMODE_VACATION\x10\b2\x84\x03\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}\x12\x7f\n" + "\tGetDevice\x12!.econet.v1alpha1.GetDeviceRequest\x1a\".econet.v1alpha1.GetDeviceResponse\")\x82\xd3\xe4\x93\x02#\x12!/v1alpha1/devices/{serial_number}\x12\x7f\n" +
@@ -626,21 +639,22 @@ var file_econet_v1alpha1_econet_proto_goTypes = []any{
} }
var file_econet_v1alpha1_econet_proto_depIdxs = []int32{ var file_econet_v1alpha1_econet_proto_depIdxs = []int32{
8, // 0: econet.v1alpha1.Device.last_updated:type_name -> google.protobuf.Timestamp 8, // 0: econet.v1alpha1.Device.last_updated:type_name -> google.protobuf.Timestamp
1, // 1: econet.v1alpha1.ListDevicesResponse.devices:type_name -> econet.v1alpha1.Device 0, // 1: econet.v1alpha1.Device.supported_modes:type_name -> econet.v1alpha1.Mode
1, // 2: econet.v1alpha1.GetDeviceResponse.device:type_name -> econet.v1alpha1.Device 1, // 2: econet.v1alpha1.ListDevicesResponse.devices:type_name -> econet.v1alpha1.Device
0, // 3: econet.v1alpha1.SetModeRequest.mode:type_name -> econet.v1alpha1.Mode 1, // 3: econet.v1alpha1.GetDeviceResponse.device:type_name -> econet.v1alpha1.Device
1, // 4: econet.v1alpha1.SetModeResponse.device:type_name -> econet.v1alpha1.Device 0, // 4: econet.v1alpha1.SetModeRequest.mode:type_name -> econet.v1alpha1.Mode
2, // 5: econet.v1alpha1.EconetService.ListDevices:input_type -> econet.v1alpha1.ListDevicesRequest 1, // 5: econet.v1alpha1.SetModeResponse.device:type_name -> econet.v1alpha1.Device
4, // 6: econet.v1alpha1.EconetService.GetDevice:input_type -> econet.v1alpha1.GetDeviceRequest 2, // 6: econet.v1alpha1.EconetService.ListDevices:input_type -> econet.v1alpha1.ListDevicesRequest
6, // 7: econet.v1alpha1.EconetService.SetMode:input_type -> econet.v1alpha1.SetModeRequest 4, // 7: econet.v1alpha1.EconetService.GetDevice:input_type -> econet.v1alpha1.GetDeviceRequest
3, // 8: econet.v1alpha1.EconetService.ListDevices:output_type -> econet.v1alpha1.ListDevicesResponse 6, // 8: econet.v1alpha1.EconetService.SetMode:input_type -> econet.v1alpha1.SetModeRequest
5, // 9: econet.v1alpha1.EconetService.GetDevice:output_type -> econet.v1alpha1.GetDeviceResponse 3, // 9: econet.v1alpha1.EconetService.ListDevices:output_type -> econet.v1alpha1.ListDevicesResponse
7, // 10: econet.v1alpha1.EconetService.SetMode:output_type -> econet.v1alpha1.SetModeResponse 5, // 10: econet.v1alpha1.EconetService.GetDevice:output_type -> econet.v1alpha1.GetDeviceResponse
8, // [8:11] is the sub-list for method output_type 7, // 11: econet.v1alpha1.EconetService.SetMode:output_type -> econet.v1alpha1.SetModeResponse
5, // [5:8] is the sub-list for method input_type 9, // [9:12] is the sub-list for method output_type
5, // [5:5] is the sub-list for extension type_name 6, // [6:9] is the sub-list for method input_type
5, // [5:5] is the sub-list for extension extendee 6, // [6:6] is the sub-list for extension type_name
0, // [0:5] is the sub-list for field 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() }
File diff suppressed because one or more lines are too long
+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
+10 -2
View File
@@ -213,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."
@@ -248,10 +255,11 @@
"MODE_HIGH_DEMAND", "MODE_HIGH_DEMAND",
"MODE_GAS", "MODE_GAS",
"MODE_PERFORMANCE", "MODE_PERFORMANCE",
"MODE_VACATION" "MODE_VACATION",
"MODE_ELECTRIC_GAS"
], ],
"default": "MODE_UNSPECIFIED", "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." "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": { "v1alpha1SetModeResponse": {
"type": "object", "type": "object",
+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
+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)
}
})
}
}
+10 -1
View File
@@ -22,6 +22,7 @@ import (
// Hand-written tools (switch back by using this import instead): // Hand-written tools (switch back by using this import instead):
// econetmcp "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetmcp" // 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"
) )
@@ -32,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
} }
@@ -48,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
@@ -100,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(),
} }
} }
+31 -11
View File
@@ -34,11 +34,12 @@ const (
// It is safe for concurrent use: Refresh swaps the device map under a lock, // It is safe for concurrent use: Refresh swaps the device map under a lock,
// and readers take a read lock. // and readers take a read lock.
type Client struct { type Client struct {
email string email string
password string password string
costPerKWH float64 costPerKWH float64
http *http.Client disableWaterUsage bool
log *zerolog.Logger http *http.Client
log *zerolog.Logger
mu sync.RWMutex mu sync.RWMutex
token string token string
@@ -52,12 +53,13 @@ func New(cfg *config.ServiceConfig, log *zerolog.Logger) *Client {
transport := http.DefaultTransport.(*http.Transport).Clone() transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: cfg.EconetTLSInsecure} //nolint:gosec // opt-in via EconetTLSInsecure transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: cfg.EconetTLSInsecure} //nolint:gosec // opt-in via EconetTLSInsecure
return &Client{ return &Client{
email: cfg.EconetEmail, email: cfg.EconetEmail,
password: cfg.EconetPassword, password: cfg.EconetPassword,
costPerKWH: cfg.CostPerKWH, costPerKWH: cfg.CostPerKWH,
http: &http.Client{Transport: transport, Timeout: 15 * time.Second}, disableWaterUsage: cfg.DisableWaterUsage,
log: log, http: &http.Client{Transport: transport, Timeout: 15 * time.Second},
devices: map[string]*Device{}, log: log,
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
} }
@@ -160,6 +163,20 @@ func (c *Client) SetMode(ctx context.Context, serial, mode string) error {
return nil 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 = ""
@@ -234,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 {
+10 -1
View File
@@ -45,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
@@ -68,6 +72,9 @@ func newDevice(info map[string]json.RawMessage, now time.Time) *Device {
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) d.modeEnumText = modeLabels(info)
if b, err := json.Marshal(info); err == nil {
d.Raw = b
}
return d return d
} }
@@ -228,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":
@@ -244,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
}
+26
View File
@@ -71,6 +71,17 @@ func (s *EconetGRPCServer) GetDevice(ctx context.Context, req *pb.GetDeviceReque
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 // modeSlugs maps the settable proto Mode enum to the normalized slugs the
// client (and Device.mode) use. MODE_UNSPECIFIED is intentionally absent; // client (and Device.mode) use. MODE_UNSPECIFIED is intentionally absent;
// protovalidate rejects it before we get here. // protovalidate rejects it before we get here.
@@ -83,6 +94,21 @@ var modeSlugs = map[pb.Mode]string{
pb.Mode_MODE_GAS: "gas", pb.Mode_MODE_GAS: "gas",
pb.Mode_MODE_PERFORMANCE: "performance", pb.Mode_MODE_PERFORMANCE: "performance",
pb.Mode_MODE_VACATION: "vacation", 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) ( func (s *EconetGRPCServer) SetMode(ctx context.Context, req *pb.SetModeRequest) (
+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)
}
}
}
+2
View File
@@ -28,6 +28,7 @@ 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 // Mode is a settable operating mode for a water heater. The values mirror the
@@ -44,6 +45,7 @@ enum Mode {
MODE_GAS = 6; MODE_GAS = 6;
MODE_PERFORMANCE = 7; MODE_PERFORMANCE = 7;
MODE_VACATION = 8; MODE_VACATION = 8;
MODE_ELECTRIC_GAS = 9; // combined electric/gas backup mode (e.g. hybrid units)
} }
message ListDevicesRequest {} message ListDevicesRequest {}