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

This commit is contained in:
2026-07-16 15:35:12 -04:00
parent 8e0e59db16
commit 52ed5a7e3a
10 changed files with 731 additions and 25 deletions
+58
View File
@@ -113,6 +113,53 @@ func (c *Client) Refresh(ctx context.Context) error {
return nil
}
// SetMode changes a device's operating mode. It resolves the normalized mode
// slug to the device's @MODE enum index and publishes a desired-state message
// to the EcoNet cloud over ClearBlade's REST publish endpoint (the HTTP
// equivalent of the app's MQTT publish). The change is applied asynchronously;
// the new mode is only reflected after a subsequent Refresh.
func (c *Client) SetMode(ctx context.Context, serial, mode string) error {
c.mu.RLock()
d, account := c.devices[serial], c.accountID
c.mu.RUnlock()
if d == nil {
return fmt.Errorf("econet: unknown device %q", serial)
}
idx, ok := d.modeIndex(mode)
if !ok {
return fmt.Errorf("econet: device %q does not support mode %q (supported: %s)",
serial, mode, strings.Join(d.SupportedModes(), ", "))
}
if err := c.ensureAuth(ctx); err != nil {
return err
}
desired, err := json.Marshal(map[string]any{
"transactionId": transactionID(),
"device_name": d.DeviceID,
"serial_number": d.SerialNumber,
"@MODE": idx,
})
if err != nil {
return err
}
publish := map[string]any{
"topic": "user/" + account + "/device/desired",
"body": string(desired),
"qos": 1,
}
path := "/message/" + clearBladeSystemKey + "/publish"
if err := c.do(ctx, path, c.userToken(), publish, nil); err != nil {
c.clearToken() // token may have expired; re-auth next call
return fmt.Errorf("econet: set mode: %w", err)
}
c.log.Info().Str("serial", serial).Str("mode", mode).Int("modeIndex", idx).
Msg("econet: published mode change")
return nil
}
func (c *Client) clearToken() {
c.mu.Lock()
c.token = ""
@@ -274,6 +321,11 @@ func (c *Client) do(ctx context.Context, path, token string, body, out any) erro
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, bytes.TrimSpace(raw))
}
// The publish endpoint replies 200 with an empty (or non-JSON) body; callers
// that don't need the response pass out == nil.
if out == nil || len(bytes.TrimSpace(raw)) == 0 {
return nil
}
return json.Unmarshal(raw, out)
}
@@ -285,6 +337,12 @@ func (c *Client) userToken() string {
// --- usage helpers -------------------------------------------------------
// transactionID mirrors the identifier the Rheem Android app stamps on each
// desired-state publish: "ANDROID_" + a local second-resolution timestamp.
func transactionID() string {
return "ANDROID_" + time.Now().Format("2006-01-02T15:04:05")
}
// dayWindow returns ISO-8601 start/end strings covering the current local day,
// matching pyeconet's formats: energy includes the timezone offset, water does
// not (the upstream parser is picky).
+27
View File
@@ -26,6 +26,11 @@ type Device struct {
Running bool // true when actively heating (decoded from @RUNNING over REST)
RunningState string // normalized @RUNNING label: "compressor-running" | "element-running" | "idle" | ...
// modeEnumText is the raw @MODE enum label list; the slice index is the
// integer value published to change the mode. Empty for units without a
// settable @MODE (mode changes are unsupported for those).
modeEnumText []string
Setpoint int
SetpointMin int
SetpointMax int
@@ -62,9 +67,31 @@ func newDevice(info map[string]json.RawMessage, now time.Time) *Device {
d.FriendlyName = friendlyName(info, d.DeviceID)
d.Setpoint, d.SetpointMin, d.SetpointMax = setpoint(info["@SETPOINT"])
d.Running, d.RunningState = running(info)
d.modeEnumText = modeLabels(info)
return d
}
// SupportedModes returns the normalized mode slugs this device can be set to,
// in publish-index order. Empty for units without a settable @MODE.
func (d *Device) SupportedModes() []string {
out := make([]string, 0, len(d.modeEnumText))
for _, label := range d.modeEnumText {
out = append(out, modeFromEnumText(label))
}
return out
}
// modeIndex maps a normalized mode slug to the integer value the EcoNet cloud
// expects in a @MODE publish, matching pyeconet's enumText-position lookup.
func (d *Device) modeIndex(slug string) (int, bool) {
for i, label := range d.modeEnumText {
if modeFromEnumText(label) == slug {
return i, true
}
}
return 0, false
}
// --- field decoders ------------------------------------------------------
func connected(info map[string]json.RawMessage) bool {