diff --git a/meter/zendure.go b/meter/zendure.go new file mode 100644 index 0000000000..e3fb541b25 --- /dev/null +++ b/meter/zendure.go @@ -0,0 +1,71 @@ +package meter + +import ( + "fmt" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/meter/zendure" + "github.com/evcc-io/evcc/util" +) + +func init() { + registry.Add("zendure", NewZendureFromConfig) +} + +type Zendure struct { + usage string + conn *zendure.Connection +} + +// NewZendureFromConfig creates a Zendure meter from generic config +func NewZendureFromConfig(other map[string]interface{}) (api.Meter, error) { + cc := struct { + Usage, Account, Serial string + Timeout time.Duration + }{ + Timeout: 30 * time.Second, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + conn, err := zendure.NewConnection(cc.Account, cc.Serial, cc.Timeout) + if err != nil { + return nil, err + } + + c := &Zendure{ + usage: cc.Usage, + conn: conn, + } + + return c, err +} + +// CurrentPower implements the api.Meter interface +func (c *Zendure) CurrentPower() (float64, error) { + res, err := c.conn.Data() + if err != nil { + return 0, err + } + + switch c.usage { + case "pv": + return float64(res.SolarInputPower), nil + case "battery": + return float64(res.GridInputPower) - float64(res.OutputHomePower), nil + default: + return 0, fmt.Errorf("invalid usage: %s", c.usage) + } +} + +// Soc implements the api.Battery interface +func (c *Zendure) Soc() (float64, error) { + res, err := c.conn.Data() + if err != nil { + return 0, err + } + return float64(res.ElectricLevel), nil +} diff --git a/meter/zendure/connection.go b/meter/zendure/connection.go new file mode 100644 index 0000000000..758d4ad83a --- /dev/null +++ b/meter/zendure/connection.go @@ -0,0 +1,86 @@ +package zendure + +import ( + "encoding/json" + "net" + "strconv" + "sync" + "time" + + "dario.cat/mergo" + "github.com/evcc-io/evcc/provider/mqtt" + "github.com/evcc-io/evcc/util" +) + +var ( + mu sync.Mutex + connections = make(map[string]*Connection) +) + +type Connection struct { + log *util.Logger + data *util.Monitor[Data] +} + +func NewConnection(account, serial string, timeout time.Duration) (*Connection, error) { + mu.Lock() + defer mu.Unlock() + + key := account + serial + if conn, ok := connections[key]; ok { + return conn, nil + } + + res, err := MqttCredentials(account, serial) + if err != nil { + return nil, err + } + + log := util.NewLogger("zendure") + client, err := mqtt.NewClient( + log, + net.JoinHostPort(res.Data.MqttUrl, strconv.Itoa(res.Data.Port)), res.Data.AppKey, res.Data.Secret, + "", 0, false, "", "", "", + ) + if err != nil { + return nil, err + } + + conn := &Connection{ + log: log, + data: util.NewMonitor[Data](timeout), + } + + topic := res.Data.AppKey + "/#" + if err := client.Listen(topic, conn.handler); err != nil { + return nil, err + } + + connections[key] = conn + + return conn, nil +} + +func (c *Connection) handler(data string) { + var res Payload + if err := json.Unmarshal([]byte(data), &res); err != nil { + c.log.ERROR.Println(err) + return + } + + if res.Data == nil { + return + } + + c.data.SetFunc(func(v Data) Data { + if err := mergo.Merge(&v, res.Data); err != nil { + c.log.ERROR.Println(err) + } + + return v + }) +} + +func (c *Connection) Data() (Data, error) { + return c.data.Get() +} diff --git a/meter/zendure/credentials.go b/meter/zendure/credentials.go new file mode 100644 index 0000000000..a64bdccd5d --- /dev/null +++ b/meter/zendure/credentials.go @@ -0,0 +1,31 @@ +package zendure + +import ( + "errors" + "net/http" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" +) + +const CredentialsUri = "https://app.zendure.tech/eu/developer/api/apply" + +func MqttCredentials(account, serial string) (CredentialsResponse, error) { + client := request.NewHelper(util.NewLogger("zendure")) + + data := CredentialsRequest{ + SnNumber: serial, + Account: account, + } + + req, _ := request.New(http.MethodPost, CredentialsUri, request.MarshalJSON(data), request.JSONEncoding) + + var res CredentialsResponse + err := client.DoJSON(req, &res) + + if err == nil && !res.Success { + err = errors.New(res.Msg) + } + + return res, err +} diff --git a/meter/zendure/types.go b/meter/zendure/types.go new file mode 100644 index 0000000000..bdba8ae5a4 --- /dev/null +++ b/meter/zendure/types.go @@ -0,0 +1,60 @@ +package zendure + +type CredentialsRequest struct { + SnNumber string `json:"snNumber"` + Account string `json:"account"` +} + +type CredentialsResponse struct { + Success bool `json:"success"` + Data struct { + AppKey string `json:"appKey"` + Secret string `json:"secret"` + MqttUrl string `json:"mqttUrl"` + Port int `json:"port"` + } + Msg string `json:"msg"` +} + +type Payload struct { + *Command + *Data +} + +type Command struct { + CommandTopic string `json:"command_topic"` + DeviceClass string `json:"device_class"` + Name string `json:"name"` + PayloadOff bool `json:"payload_off"` + PayloadOn bool `json:"payload_on"` + StateOff bool `json:"state_off"` + StateOn bool `json:"state_on"` + StateTopic string `json:"state_topic"` + UniqueId string `json:"unique_id"` + UnitOfMeasurement string `json:"unit_of_measurement"` + ValueTemplate string `json:"value_template"` +} + +type Data struct { + AcMode int `json:"acMode"` // 1, + BuzzerSwitch bool `json:"buzzerSwitch"` // false, + ElectricLevel int `json:"electricLevel"` // 7, + GridInputPower int `json:"gridInputPower"` // 99, + HeatState int `json:"heatState"` // 0, + HubState int `json:"hubState"` // 0, + HyperTmp int `json:"hyperTmp"` // 2981, + InputLimit int `json:"inputLimit"` // 100, + InverseMaxPower int `json:"inverseMaxPower"` // 1200, + MasterSwitch bool `json:"masterSwitch"` // true, + OutputLimit int `json:"outputLimit"` // 0, + OutputPackPower int `json:"outputPackPower"` // 70, + OutputHomePower int `json:"outputHomePower"` // 70, + PackNum int `json:"packNum"` // 1, + PackState int `json:"packState"` // 0, + RemainInputTime int `json:"remainInputTime"` // 59940, + RemainOutTime int `json:"remainOutTime"` // 59940, + Sn string `json:"sn"` // "EE1LH", + SocSet int `json:"socSet"` // 1000, + SolarInputPower int `json:"solarInputPower"` // 0, + WifiState bool `json:"wifiState"` // true +} diff --git a/templates/definition/meter/zendure.yaml b/templates/definition/meter/zendure.yaml new file mode 100644 index 0000000000..f57b901722 --- /dev/null +++ b/templates/definition/meter/zendure.yaml @@ -0,0 +1,22 @@ +template: zendure +products: + - brand: Zendure + description: + generic: Hyper V +requirements: + evcc: ["skiptest"] +params: + - name: usage + choice: ["pv", "battery"] + - name: account + - name: serial + - name: capacity + default: 2 + advanced: true + - name: timeout +render: | + type: zendure + usage: {{ .usage }} + account: {{ .account }} + serial: {{ .serial }} + timeout: {{ .timeout }}