// Package econetclient is a minimal REST client for the Rheem EcoNet cloud // (ClearBlade backend). It authenticates, lists devices, and reads daily // energy/water usage over plain HTTPS — no MQTT. Callers drive it by calling // Refresh on a ticker and reading the decoded Device snapshots. package econetclient import ( "bytes" "context" "crypto/tls" "encoding/json" "fmt" "io" "net/http" "strings" "sync" "time" "github.com/rs/zerolog" "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/config" ) // Static credentials embedded in the Rheem EcoNet Android app; the ClearBlade // backend uses them to identify the calling app independent of the per-user // auth. Same values pyeconet and kevinburke/rheemcloud-go use. const ( clearBladeSystemKey = "e2e699cb0bb0bbb88fc8858cb5a401" clearBladeSystemSecret = "E2E699CB0BE6C6FADDB1B0BC9A20" baseURL = "https://rheem.clearblade.com/api/v/1" ) // Client talks to the EcoNet REST API and caches the latest device snapshot. // It is safe for concurrent use: Refresh swaps the device map under a lock, // and readers take a read lock. type Client struct { email string password string costPerKWH float64 http *http.Client log *zerolog.Logger mu sync.RWMutex token string accountID string devices map[string]*Device } // New builds a Client from config. It performs no network I/O; call Refresh // to authenticate and populate devices. func New(cfg *config.ServiceConfig, log *zerolog.Logger) *Client { transport := http.DefaultTransport.(*http.Transport).Clone() transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: cfg.EconetTLSInsecure} //nolint:gosec // opt-in via EconetTLSInsecure return &Client{ email: cfg.EconetEmail, password: cfg.EconetPassword, costPerKWH: cfg.CostPerKWH, http: &http.Client{Transport: transport, Timeout: 15 * time.Second}, log: log, devices: map[string]*Device{}, } } // CostPerKWH exposes the configured electricity price for the usage gauges. func (c *Client) CostPerKWH() float64 { return c.costPerKWH } // Devices returns a snapshot slice of the currently-known devices. Empty until // the first successful Refresh. func (c *Client) Devices() []*Device { c.mu.RLock() defer c.mu.RUnlock() out := make([]*Device, 0, len(c.devices)) for _, d := range c.devices { out = append(out, d) } return out } // Device looks up a single device by serial number, or nil if unknown. func (c *Client) Device(serial string) *Device { c.mu.RLock() defer c.mu.RUnlock() return c.devices[serial] } // Refresh authenticates if needed, re-fetches the device list, reads each // unit's daily usage, and atomically replaces the cached snapshot. On an auth // failure it clears the token so the next call re-authenticates. func (c *Client) Refresh(ctx context.Context) error { if err := c.ensureAuth(ctx); err != nil { return err } equipment, err := c.fetchDevices(ctx) if err != nil { c.clearToken() // force re-auth next time in case the token expired return err } now := time.Now() devices := make(map[string]*Device, len(equipment)) for _, raw := range equipment { d := newDevice(raw, now) if d.SerialNumber == "" { continue } c.fetchUsage(ctx, d) devices[d.SerialNumber] = d } c.mu.Lock() c.devices = devices c.mu.Unlock() return nil } func (c *Client) clearToken() { c.mu.Lock() c.token = "" c.mu.Unlock() } func (c *Client) ensureAuth(ctx context.Context) error { c.mu.RLock() have := c.token != "" c.mu.RUnlock() if have { return nil } return c.authenticate(ctx) } // --- REST calls ---------------------------------------------------------- func (c *Client) authenticate(ctx context.Context) error { body := map[string]string{"email": c.email, "password": c.password} var ar struct { UserToken string `json:"user_token"` Options struct { Success bool `json:"success"` AccountID string `json:"account_id"` Message string `json:"message"` } `json:"options"` } if err := c.do(ctx, "/user/auth", "", body, &ar); err != nil { return fmt.Errorf("econet: auth: %w", err) } if !ar.Options.Success || ar.UserToken == "" || ar.Options.AccountID == "" { if ar.Options.Message != "" { return fmt.Errorf("econet: auth rejected: %s", ar.Options.Message) } return fmt.Errorf("econet: auth rejected (empty token/account)") } c.mu.Lock() c.token = ar.UserToken c.accountID = ar.Options.AccountID c.mu.Unlock() return nil } // fetchDevices returns the raw equipment blocks from getUserDataForApp. func (c *Client) fetchDevices(ctx context.Context) ([]map[string]json.RawMessage, error) { var resp struct { Results struct { Locations []struct { // "equiptments" is misspelled upstream; match it exactly. Equipments []map[string]json.RawMessage `json:"equiptments"` } `json:"locations"` } `json:"results"` } path := "/code/" + clearBladeSystemKey + "/getUserDataForApp" if err := c.do(ctx, path, c.userToken(), map[string]string{"resource": "friedrich"}, &resp); err != nil { return nil, fmt.Errorf("econet: device list: %w", err) } var out []map[string]json.RawMessage for _, loc := range resp.Results.Locations { out = append(out, loc.Equipments...) } return out, nil } // fetchUsage reads the device's daily energy and water totals, filling the // usage fields. Failures are logged at debug and leave the fields at zero // (e.g. units that don't track water return an error). func (c *Client) fetchUsage(ctx context.Context, d *Device) { if e, typ, err := c.usage(ctx, d, "energyUsage"); err == nil { d.EnergyKWH, d.EnergyType = e, typ } else { c.log.Debug().Err(err).Str("serial", d.SerialNumber).Msg("energy usage poll failed") } if w, _, err := c.usage(ctx, d, "waterUsage"); err == nil { d.WaterGallons = w } else { c.log.Debug().Err(err).Str("serial", d.SerialNumber).Msg("water usage poll failed") } } // usage POSTs a dynamicAction usage report and sums today's buckets. The // energy unit ("KWH"/"KBTU") is inferred from the summary message. func (c *Client) usage(ctx context.Context, d *Device, usageType string) (total float64, energyType string, err error) { start, end := dayWindow(usageType == "energyUsage") payload := map[string]string{ "ACTION": "waterheaterUsageReportView", "device_name": d.DeviceID, "serial_number": d.SerialNumber, "start_date": start, "end_date": end, "usage_type": usageType, } var raw json.RawMessage path := "/code/" + clearBladeSystemKey + "/dynamicAction" if err := c.do(ctx, path, c.userToken(), payload, &raw); err != nil { return 0, "", err } var env struct { Success bool `json:"success"` Error string `json:"error"` Results struct { Energy usageBlock `json:"energy_usage"` Water usageBlock `json:"water_usage"` } `json:"results"` } if err := json.Unmarshal(raw, &env); err != nil { return 0, "", fmt.Errorf("parse usage: %w", err) } if !env.Success { if env.Error != "" { return 0, "", fmt.Errorf("usage rejected: %s", env.Error) } return 0, "", fmt.Errorf("usage rejected") } block := env.Results.Water if usageType == "energyUsage" { block = env.Results.Energy } for _, p := range block.Data { total += p.Value } return total, energyTypeFromMessage(block.Message, d.GenericType), nil } type usageBlock struct { Data []struct { Value float64 `json:"value"` } `json:"data"` Message string `json:"message"` } // do POSTs a JSON body to path and decodes the JSON response into out. When // token is non-empty it is sent as the ClearBlade-UserToken header. func (c *Client) do(ctx context.Context, path, token string, body, out any) error { b, err := json.Marshal(body) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, bytes.NewReader(b)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("ClearBlade-SystemKey", clearBladeSystemKey) req.Header.Set("ClearBlade-SystemSecret", clearBladeSystemSecret) if token != "" { req.Header.Set("ClearBlade-UserToken", token) } resp, err := c.http.Do(req) if err != nil { return err } defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) if err != nil { return err } if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP %d: %s", resp.StatusCode, bytes.TrimSpace(raw)) } return json.Unmarshal(raw, out) } func (c *Client) userToken() string { c.mu.RLock() defer c.mu.RUnlock() return c.token } // --- usage helpers ------------------------------------------------------- // dayWindow returns ISO-8601 start/end strings covering the current local day, // matching pyeconet's formats: energy includes the timezone offset, water does // not (the upstream parser is picky). func dayWindow(energy bool) (start, end string) { now := time.Now() s := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 999_000_000, now.Location()) e := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 999_000_000, now.Location()) if energy { return pyISOMillis(s), pyISOMillis(e) } const f = "2006-01-02T15:04:05.999" return s.Format(f), e.Format(f) } // pyISOMillis emulates Python's datetime.isoformat(timespec='milliseconds') // for a tz-aware datetime: YYYY-MM-DDTHH:MM:SS.mmm±HH:MM. func pyISOMillis(t time.Time) string { s := t.Format("2006-01-02T15:04:05.000-0700") if len(s) >= 5 { // "-0700" -> "-07:00" s = s[:len(s)-2] + ":" + s[len(s)-2:] } return s } // energyTypeFromMessage extracts the unit label ("KWH"/"KBTU") from Rheem's // human summary ("You used X.Y kWh of energy today."), the 4th word per // pyeconet's heuristic, falling back to the device's generic type. func energyTypeFromMessage(msg, genericType string) string { fields := strings.Fields(msg) if len(fields) >= 4 { return strings.ToUpper(fields[3]) } if genericType == "gasWaterHeater" { return "KBTU" } return "KWH" }