add ui, new config opts

This commit is contained in:
2026-07-16 16:32:44 -04:00
parent 8effff311b
commit df01e060e4
33 changed files with 1104 additions and 37 deletions
+31 -11
View File
@@ -34,11 +34,12 @@ const (
// It is safe for concurrent use: Refresh swaps the device map under a lock,
// and readers take a read lock.
type Client struct {
email string
password string
costPerKWH float64
http *http.Client
log *zerolog.Logger
email string
password string
costPerKWH float64
disableWaterUsage bool
http *http.Client
log *zerolog.Logger
mu sync.RWMutex
token string
@@ -52,12 +53,13 @@ func New(cfg *config.ServiceConfig, log *zerolog.Logger) *Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: cfg.EconetTLSInsecure} //nolint:gosec // opt-in via EconetTLSInsecure
return &Client{
email: cfg.EconetEmail,
password: cfg.EconetPassword,
costPerKWH: cfg.CostPerKWH,
http: &http.Client{Transport: transport, Timeout: 15 * time.Second},
log: log,
devices: map[string]*Device{},
email: cfg.EconetEmail,
password: cfg.EconetPassword,
costPerKWH: cfg.CostPerKWH,
disableWaterUsage: cfg.DisableWaterUsage,
http: &http.Client{Transport: transport, Timeout: 15 * time.Second},
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))
for _, raw := range equipment {
d := newDevice(raw, now)
c.traceRawEquipment(d.SerialNumber, raw)
if d.SerialNumber == "" {
continue
}
@@ -160,6 +163,20 @@ func (c *Client) SetMode(ctx context.Context, serial, mode string) error {
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() {
c.mu.Lock()
c.token = ""
@@ -234,6 +251,9 @@ func (c *Client) fetchUsage(ctx context.Context, d *Device) {
} else {
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 {
d.WaterGallons = w
} else {
+10 -1
View File
@@ -45,6 +45,10 @@ type Device struct {
EnergyKWH float64
EnergyType string // "KWH" or "KBTU"
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
@@ -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.Running, d.RunningState = running(info)
d.modeEnumText = modeLabels(info)
if b, err := json.Marshal(info); err == nil {
d.Raw = b
}
return d
}
@@ -228,6 +235,8 @@ func modeFromEnumText(s string) string {
return "heat-pump"
case "HIGH_DEMAND":
return "high-demand"
case "ELECTRIC_GAS":
return "electric-gas"
case "GAS":
return "gas"
case "PERFORMANCE":
@@ -244,7 +253,7 @@ func modeFromEnumText(s string) string {
// series regardless of which one is currently active.
var KnownModes = []string{
"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
+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)
}
}