package econetclient import ( "encoding/json" "strconv" "strings" "time" ) // Device is a flat snapshot of one piece of Rheem equipment, decoded from // the getUserDataForApp REST response. Fields are computed once per refresh; // consumers read them directly. WiFiSignal only arrives over MQTT (which this // client does not use), so it stays at its zero value. type Device struct { SerialNumber string DeviceID string FriendlyName string Type string // water_heater | thermostat | unknown GenericType string // e.g. heatpumpWaterHeater, gasWaterHeater Connected bool WiFiSignal int // MQTT-only; always 0 here Mode string Enabled bool Running bool // true when actively heating (decoded from @RUNNING over REST) RunningState string // normalized @RUNNING label: "compressor-running" | "element-running" | "idle" | ... Setpoint int SetpointMin int SetpointMax int HotWaterAvailability int // -1 if not reported, else 0/33/66/100 AlertCount int Away bool LastUpdated time.Time // Usage totals for the current day, populated by Client.Refresh. EnergyKWH float64 EnergyType string // "KWH" or "KBTU" WaterGallons float64 } // newDevice decodes an equipment block (the raw JSON map for one unit) into // a flat Device. It ports the accessor logic from kevinburke/rheemcloud-go // so behaviour matches, minus the MQTT-only fields. func newDevice(info map[string]json.RawMessage, now time.Time) *Device { d := &Device{ DeviceID: jsonStr(info["device_name"]), SerialNumber: jsonStr(info["serial_number"]), Type: deviceTypeString(jsonStr(info["device_type"])), GenericType: jsonStr(info["@TYPE"]), Connected: connected(info), Away: jsonBool(info["@AWAY"]), AlertCount: alertCount(info["@ALERTCOUNT"]), HotWaterAvailability: hotWater(info["@HOTWATER"]), Enabled: enabled(info), Mode: mode(info), LastUpdated: now, } d.FriendlyName = friendlyName(info, d.DeviceID) d.Setpoint, d.SetpointMin, d.SetpointMax = setpoint(info["@SETPOINT"]) d.Running, d.RunningState = running(info) return d } // --- field decoders ------------------------------------------------------ func connected(info map[string]json.RawMessage) bool { v, ok := info["@CONNECTED"] if !ok { return true // older units omit it and are assumed connected } return jsonBool(v) } func friendlyName(info map[string]json.RawMessage, deviceID string) string { if obj := decodeDatapoint(info["@NAME"]); obj != nil { if s := jsonStr(obj.Value); s != "" { return s } } return deviceID } func alertCount(v json.RawMessage) int { if len(v) == 0 { return 0 } var n float64 if json.Unmarshal(v, &n) == nil { return int(n) } var s string if json.Unmarshal(v, &s) == nil { if i, err := strconv.Atoi(s); err == nil { return i } } return 0 } func hotWater(v json.RawMessage) int { var icon string if len(v) == 0 || json.Unmarshal(v, &icon) != nil { return -1 } switch { case strings.Contains(icon, "ic_tank_hundread_percent"): return 100 case strings.Contains(icon, "ic_tank_fourty_percent"): return 66 case strings.Contains(icon, "ic_tank_ten_percent"): return 33 case strings.Contains(icon, "ic_tank_empty"), strings.Contains(icon, "ic_tank_zero_percent"): return 0 default: return -1 } } func setpoint(v json.RawMessage) (val, min, max int) { obj := decodeDatapoint(v) if obj == nil { return 0, 0, 0 } var n float64 _ = json.Unmarshal(obj.Value, &n) return int(n), int(obj.Constraints.LowerLimit), int(obj.Constraints.UpperLimit) } // enabled reports the master on/off state, porting rheemcloud's Device.Enabled. func enabled(info map[string]json.RawMessage) bool { if _, ok := info["@MODE"]; ok { labels := modeLabels(info) if obj := decodeDatapoint(info["@MODE"]); obj != nil { if idx, ok := datapointIndex(obj); ok && idx >= 0 && idx < len(labels) { return !strings.EqualFold(labels[idx], "OFF") } } } if _, ok := info["@ENABLED"]; ok { obj := decodeDatapoint(info["@ENABLED"]) if obj == nil { return false } idx, _ := datapointIndex(obj) return idx == 1 } return false } // mode returns the current operating-mode label, porting rheemcloud's Device.Mode. func mode(info map[string]json.RawMessage) string { _, supportsModes := info["@MODE"] _, supportsOnOff := info["@ENABLED"] if supportsOnOff && !enabled(info) { return "off" } if !supportsModes { switch jsonStr(info["@TYPE"]) { case "gasWaterHeater", "tanklessWaterHeater": return "gas" default: return "electric" } } labels := modeLabels(info) obj := decodeDatapoint(info["@MODE"]) if obj == nil { return "unknown" } idx, _ := datapointIndex(obj) if idx < 0 || idx >= len(labels) { return "unknown" } return modeFromEnumText(labels[idx]) } func modeLabels(info map[string]json.RawMessage) []string { obj := decodeDatapoint(info["@MODE"]) if obj == nil { return nil } return obj.Constraints.EnumText } // modeFromEnumText normalizes a Rheem mode label into a stable slug, // matching pyeconet's WaterHeaterOperationMode labels. func modeFromEnumText(s string) string { cleaned := strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(strings.TrimRight(s, " "), " ", "_"), "/", "_")) switch cleaned { case "OFF": return "off" case "ELECTRIC", "ELECTRIC_MODE": return "electric" case "ENERGY_SAVING", "ENERGY_SAVER": return "energy-saving" case "HEAT_PUMP", "HEAT_PUMP_ONLY": return "heat-pump" case "HIGH_DEMAND": return "high-demand" case "GAS": return "gas" case "PERFORMANCE": return "performance" case "VACATION": return "vacation" default: return "unknown" } } // KnownModes are the normalized operating-mode slugs modeFromEnumText emits. // Consumers (e.g. the mode enum gauge) iterate these so every mode gets a // series regardless of which one is currently active. var KnownModes = []string{ "off", "electric", "energy-saving", "heat-pump", "high-demand", "gas", "performance", "vacation", "unknown", } // running reports whether the unit is actively heating and a normalized label // for what is running. It reads the @RUNNING datapoint, a bare string the REST // getUserDataForApp payload populates (e.g. "Compressor Running" -> true, // "compressor-running"); empty means idle. This is served over plain REST // despite older code assuming it was MQTT-only. func running(info map[string]json.RawMessage) (active bool, state string) { raw := strings.TrimSpace(jsonStr(info["@RUNNING"])) if raw == "" { return false, "idle" } state = slugify(raw) if state == "off" || state == "idle" { return false, state } return true, state } // slugify normalizes a Rheem label into a stable lowercase kebab-case slug, // e.g. "Compressor Running" -> "compressor-running". func slugify(s string) string { s = strings.ToLower(strings.TrimSpace(s)) s = strings.ReplaceAll(s, "/", "-") s = strings.Join(strings.Fields(s), "-") return s } func deviceTypeString(s string) string { switch s { case "WH": return "water_heater" case "HVAC": return "thermostat" default: return "unknown" } } // --- datapoint plumbing -------------------------------------------------- // datapointObject is the {value, status, title, constraints} shape many // "@..." fields use. Some fields are bare scalars instead; decodeDatapoint // returns nil for those and callers fall back to reading the raw value. type datapointObject struct { Value json.RawMessage `json:"value"` Status string `json:"status"` Title string `json:"title"` Constraints datapointConstraints `json:"constraints"` } type datapointConstraints struct { LowerLimit float64 `json:"lowerLimit"` UpperLimit float64 `json:"upperLimit"` EnumText []string `json:"enumText"` } func decodeDatapoint(b json.RawMessage) *datapointObject { if len(b) == 0 || !isJSONObject(b) { return nil } var obj datapointObject if json.Unmarshal(b, &obj) != nil { return nil } return &obj } // datapointIndex reads a datapoint's numeric value (mode index / enabled flag). func datapointIndex(obj *datapointObject) (int, bool) { var n float64 if json.Unmarshal(obj.Value, &n) != nil { return 0, false } return int(n), true } func isJSONObject(b json.RawMessage) bool { for _, c := range b { switch c { case ' ', '\t', '\n', '\r': continue case '{': return true default: return false } } return false } func jsonStr(v json.RawMessage) string { var s string _ = json.Unmarshal(v, &s) return s } func jsonBool(v json.RawMessage) bool { var b bool _ = json.Unmarshal(v, &b) return b }