Repository: evcc-io/evcc
Pull request #9102 (2023-07-24 16:42:20)

HomeWizard Devices: Enable Chargers and Meters
Enable full usage of HomeWizard devices in evcc

Refer to #9091

thierolm: @andig hab' den HomeWizard PR auf Basis der API Doku (siehe https://homewizard-energy-api.readthedocs.io/index.html) gebaut.
Die JSON Tests und auch die Dry run tests mit einer Fake IP in meinem Netzwerk, zeigen zumindest das die korrekten Endpoints der API aufgerufen werden:
```
thierolm@LAPTOP-KQMMVN3H:~/git-repos/evcc$ ./evcc -c ./evcc_homewizardtest.yaml -l trace charger
[main  ] INFO 2023/07/24 18:39:01 evcc 0.118.10 (aec31192)
[main  ] INFO 2023/07/24 18:39:01 using config file: ./evcc_homewizardtest.yaml
[db    ] INFO 2023/07/24 18:39:01 using sqlite database: /home/thierolm/.evcc/evcc.db
[db    ] TRACE 2023/07/24 18:39:01 SELECT count(*) FROM sqlite_master WHERE type='table' AND name="settings" -1 <nil>
[db    ] TRACE 2023/07/24 18:39:01 SELECT sql FROM sqlite_master WHERE type IN ("table","index") AND tbl_name = "settings" AND sql IS NOT NULL order by type = "table" desc 1 <nil>
[db    ] TRACE 2023/07/24 18:39:01 SELECT * FROM `settings` LIMIT 1 -1 <nil>
[db    ] TRACE 2023/07/24 18:39:01 SELECT * FROM `settings` 6 <nil>
[homewizard] TRACE 2023/07/24 18:39:01 GET http://192.168.178.200/api/v1/data
[homewizard] TRACE 2023/07/24 18:39:02 GET http://192.168.178.200/api/v1/data
[homewizard] TRACE 2023/07/24 18:39:05 GET http://192.168.178.200/api/v1/data
[homewizard] TRACE 2023/07/24 18:39:08 GET http://192.168.178.200/api/v1/data
Power:         Get "http://192.168.178.200/api/v1/data": dial tcp 192.168.178.200:80: connect: no route to host
Energy:        Get "http://192.168.178.200/api/v1/data": dial tcp 192.168.178.200:80: connect: no route to host
Charge status: Get "http://192.168.178.200/api/v1/data": dial tcp 192.168.178.200:80: connect: no route to host
Enabled:       Get "http://192.168.178.200/api/v1/data": dial tcp 192.168.178.200:80: connect: no route to host
Features:      []
```
Wenn du den PR mergst, könnte @Spor36 das Ganze mit der Nightly Version testen.
andig: Schöner PR- vielen Dank!

Patch:
diff --git a/charger/homewizard.go b/charger/homewizard.go
new file mode 100644
index 00000000..16c1f604
--- /dev/null
+++ b/charger/homewizard.go
@@ -0,0 +1,100 @@
+package charger
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/meter/homewizard"
+	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/request"
+)
+
+// HomeWizard project homepage
+// https://homewizard-energy-api.readthedocs.io/index.html
+
+// HomeWizard charger implementation
+type HomeWizard struct {
+	conn *homewizard.Connection
+	*switchSocket
+}
+
+func init() {
+	registry.Add("homewizard", NewHomeWizardFromConfig)
+}
+
+// NewHomeWizardFromConfig creates a HomeWizard charger from generic config
+func NewHomeWizardFromConfig(other map[string]interface{}) (api.Charger, error) {
+	var cc = struct {
+		embed        `mapstructure:",squash"`
+		URI          string
+		StandbyPower float64
+	}{}
+
+	if err := util.DecodeOther(other, &cc); err != nil {
+		return nil, err
+	}
+
+	return NewHomeWizard(cc.embed, cc.URI, cc.StandbyPower)
+}
+
+// NewHomeWizard creates HomeWizard charger
+func NewHomeWizard(embed embed, uri string, standbypower float64) (*HomeWizard, error) {
+	conn, err := homewizard.NewConnection(uri)
+	if err != nil {
+		return nil, err
+	}
+
+	c := &HomeWizard{
+		conn: conn,
+	}
+
+	// Check compatible product type
+	if c.conn.ProductType != "HWE-SKT" {
+		return nil, errors.New("not supported product type: " + c.conn.ProductType)
+	}
+
+	c.switchSocket = NewSwitchSocket(&embed, c.Enabled, c.conn.CurrentPower, standbypower)
+
+	return c, nil
+}
+
+// Enabled implements the api.Charger interface
+func (c *HomeWizard) Enabled() (bool, error) {
+	var res homewizard.StateResponse
+	err := c.conn.GetJSON(fmt.Sprintf("%s/data", c.conn.URI), &res)
+	return res.PowerOn, err
+}
+
+// Enable implements the api.Charger interface
+func (c *HomeWizard) Enable(enable bool) error {
+	var res homewizard.StateResponse
+	data := map[string]interface{}{
+		"power_on": enable,
+	}
+
+	req, err := request.New(http.MethodPut, fmt.Sprintf("%s/state", c.conn.URI), request.MarshalJSON(data), request.JSONEncoding)
+	if err != nil {
+		return err
+	}
+	if err := c.conn.DoJSON(req, &res); err != nil {
+		return err
+	}
+
+	switch {
+	case enable && !res.PowerOn:
+		return errors.New("switchOn failed")
+	case !enable && res.PowerOn:
+		return errors.New("switchOff failed")
+	default:
+		return nil
+	}
+}
+
+var _ api.MeterEnergy = (*HomeWizard)(nil)
+
+// TotalEnergy implements the api.MeterEnergy interface
+func (c *HomeWizard) TotalEnergy() (float64, error) {
+	return c.conn.TotalEnergy()
+}
diff --git a/meter/homewizard.go b/meter/homewizard.go
new file mode 100644
index 00000000..9c354d37
--- /dev/null
+++ b/meter/homewizard.go
@@ -0,0 +1,58 @@
+package meter
+
+import (
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/meter/homewizard"
+	"github.com/evcc-io/evcc/util"
+)
+
+// HomeWizard meter implementation
+type HomeWizard struct {
+	conn *homewizard.Connection
+}
+
+// HomeWizard meter implementation
+func init() {
+	registry.Add("homewizard", NewHomeWizardFromConfig)
+}
+
+// NewHomeWizardFromConfig creates a HomeWizard meter from generic config
+func NewHomeWizardFromConfig(other map[string]interface{}) (api.Meter, error) {
+	cc := struct {
+		URI string
+	}{}
+
+	if err := util.DecodeOther(other, &cc); err != nil {
+		return nil, err
+	}
+
+	return NewHomeWizard(cc.URI)
+}
+
+// NewHomeWizard creates HomeWizard meter
+func NewHomeWizard(uri string) (*HomeWizard, error) {
+	conn, err := homewizard.NewConnection(uri)
+	if err != nil {
+		return nil, err
+	}
+
+	c := &HomeWizard{
+		conn: conn,
+	}
+
+	return c, err
+}
+
+var _ api.Meter = (*HomeWizard)(nil)
+
+// CurrentPower implements the api.Meter interface
+func (c *HomeWizard) CurrentPower() (float64, error) {
+	return c.conn.CurrentPower()
+}
+
+var _ api.MeterEnergy = (*HomeWizard)(nil)
+
+// TotalEnergy implements the api.MeterEnergy interface
+func (c *HomeWizard) TotalEnergy() (float64, error) {
+	return c.conn.TotalEnergy()
+}
diff --git a/meter/homewizard/connection.go b/meter/homewizard/connection.go
new file mode 100644
index 00000000..4e0a8bbc
--- /dev/null
+++ b/meter/homewizard/connection.go
@@ -0,0 +1,61 @@
+package homewizard
+
+import (
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/request"
+	"github.com/evcc-io/evcc/util/transport"
+)
+
+// Connection is the homewizard connection
+type Connection struct {
+	*request.Helper
+	URI         string
+	ProductType string
+}
+
+// NewConnection creates a homewizard connection
+func NewConnection(uri string) (*Connection, error) {
+	if uri == "" {
+		return nil, errors.New("missing uri")
+	}
+
+	log := util.NewLogger("homewizard")
+	c := &Connection{
+		Helper: request.NewHelper(log),
+		URI:    fmt.Sprintf("%s/api", util.DefaultScheme(strings.TrimRight(uri, "/"), "http")),
+	}
+
+	c.Client.Transport = request.NewTripper(log, transport.Insecure())
+
+	// Check and set API version + product type
+	var res ApiResponse
+	if err := c.GetJSON(c.URI, &res); err != nil {
+		return c, err
+	}
+	if res.ApiVersion != "v1" {
+		return nil, errors.New("not supported api version: " + res.ApiVersion)
+	}
+
+	c.URI = c.URI + "/" + res.ApiVersion
+	c.ProductType = res.ProductType
+
+	return c, nil
+}
+
+// CurrentPower implements the api.Meter interface
+func (d *Connection) CurrentPower() (float64, error) {
+	var res DataResponse
+	err := d.GetJSON(fmt.Sprintf("%s/data", d.URI), &res)
+	return res.ActivePowerW, err
+}
+
+// TotalEnergy implements the api.MeterEnergy interface
+func (d *Connection) TotalEnergy() (float64, error) {
+	var res DataResponse
+	err := d.GetJSON(fmt.Sprintf("%s/data", d.URI), &res)
+	return res.TotalPowerImportT1kWh + res.TotalPowerImportT2kWh + res.TotalPowerImportT3kWh + res.TotalPowerImportT4kWh, err
+}
diff --git a/meter/homewizard/types.go b/meter/homewizard/types.go
new file mode 100644
index 00000000..a0039fa7
--- /dev/null
+++ b/meter/homewizard/types.go
@@ -0,0 +1,24 @@
+package homewizard
+
+// ApiResponse returns allows you to get basic information from the HomeWizard Energy Socket
+// https://homewizard-energy-api.readthedocs.io/endpoints.html#basic-information-api
+type ApiResponse struct {
+	ProductType string `json:"product_type"`
+	ApiVersion  string `json:"api_version"`
+}
+
+// StateResponse returns the actual state of the HomeWizard Energy Socket
+// https://homewizard-energy-api.readthedocs.io/endpoints.html#recent-measurement-api-v1-data
+type StateResponse struct {
+	PowerOn bool `json:"power_on"`
+}
+
+// DataResponse returns the most recent measurements from the HomeWizard device
+// https://homewizard-energy-api.readthedocs.io/endpoints.html#state-api-v1-state
+type DataResponse struct {
+	ActivePowerW          float64 `json:"active_power_w"`
+	TotalPowerImportT1kWh float64 `json:"total_power_import_t1_kwh"`
+	TotalPowerImportT2kWh float64 `json:"total_power_import_t2_kwh"`
+	TotalPowerImportT3kWh float64 `json:"total_power_import_t3_kwh"`
+	TotalPowerImportT4kWh float64 `json:"total_power_import_t4_kwh"`
+}
diff --git a/templates/definition/charger/homewizard.yaml b/templates/definition/charger/homewizard.yaml
new file mode 100644
index 00000000..fd839424
--- /dev/null
+++ b/templates/definition/charger/homewizard.yaml
@@ -0,0 +1,10 @@
+template: homewizard
+products:
+  - brand: HomeWizard
+group: switchsockets
+params:
+  - name: host
+  - preset: switchsocket
+render: |
+  type: homewizard
+  uri: http://{{ .host }}
diff --git a/templates/definition/charger/tasmota.yaml b/templates/definition/charger/tasmota.yaml
index fe3e2d46..2ef4c522 100644
--- a/templates/definition/charger/tasmota.yaml
+++ b/templates/definition/charger/tasmota.yaml
@@ -3,7 +3,6 @@ products:
   - brand: Tasmota
 group: switchsockets
 params:
-  - name: host
   - name: host
   - name: user
     required: false
diff --git a/templates/definition/meter/homewizard.yaml b/templates/definition/meter/homewizard.yaml
index 8d33094c..6a292160 100644
--- a/templates/definition/meter/homewizard.yaml
+++ b/templates/definition/meter/homewizard.yaml
@@ -5,22 +5,9 @@ products:
       generic: Wi-Fi P1 Meter
 params:
   - name: usage
-    choice: ["grid"]
+    choice: ["grid", "pv"]
   - name: host
     required: true
 render: |
-  type: custom
-  power:
-    source: http
-    uri: http://{{ .host }}/api/v1/data
-    method: GET
-    headers:
-      - content-type: application/json
-    jq: .active_power_w
-  energy:
-    source: http
-    uri: http://{{ .host }}/api/v1/data
-    method: GET
-    headers:
-      - content-type: application/json
-    jq: '[to_entries[] | select(.key|startswith("total_power_import_t")) | .value] | add'
+  type: homewizard
+  uri: http://{{ .host }}
diff --git a/templates/definition/meter/mtec-eb-gen2.yaml b/templates/definition/meter/mtec-eb-gen2.yaml
index a465f14d..647ed055 100644
--- a/templates/definition/meter/mtec-eb-gen2.yaml
+++ b/templates/definition/meter/mtec-eb-gen2.yaml
@@ -8,6 +8,7 @@ params:
     choice: ["grid", "pv", "battery"]
   - name: modbus
     choice: ["rs485"]
+    id: 247
 render: |
   type: custom
   {{- if eq .usage "grid" }}
@@ -18,6 +19,7 @@ render: |
       address: 11000 # Zähler (NVP) Leistung
       type: holding
       decode: int32
+    scale: -1
   {{- end }}
   {{- if eq .usage "pv" }}
   power:
diff --git a/templates/definition/meter/mtec-eb-gen3.yaml b/templates/definition/meter/mtec-eb-gen3.yaml
index 34adb214..ca07ed93 100644
--- a/templates/definition/meter/mtec-eb-gen3.yaml
+++ b/templates/definition/meter/mtec-eb-gen3.yaml
@@ -8,6 +8,7 @@ params:
     choice: ["grid", "pv", "battery"]
   - name: modbus
     choice: ["rs485"]
+    id: 247
 render: |
   type: custom
   {{- if eq .usage "grid" }}
@@ -18,6 +19,7 @@ render: |
       address: 11000 # Zähler (NVP) Leistung
       type: holding
       decode: int32
+    scale: -1
   {{- end }}
   {{- if eq .usage "pv" }}
   power:
diff --git a/templates/docs/charger/homewizard_0.yaml b/templates/docs/charger/homewizard_0.yaml
new file mode 100644
index 00000000..5b15f360
--- /dev/null
+++ b/templates/docs/charger/homewizard_0.yaml
@@ -0,0 +1,16 @@
+product:
+  brand: HomeWizard
+  group: Schaltbare Steckdosen
+render:
+  - default: |
+      type: template
+      template: homewizard
+      host: 192.0.2.2 # IP-Adresse oder Hostname
+      standbypower: 15 # Leistung oberhalb des angegebenen Wertes wird als Ladeleistung gewertet (Optional)
+    advanced: |
+      type: template
+      template: homewizard
+      host: 192.0.2.2 # IP-Adresse oder Hostname
+      standbypower: 15 # Leistung oberhalb des angegebenen Wertes wird als Ladeleistung gewertet (Optional)
+      integrateddevice: # Optional
+      icon: # Icon in der Benutzeroberfläche (Optional)
diff --git a/templates/docs/meter/homewizard_0.yaml b/templates/docs/meter/homewizard_0.yaml
index 4ccb23f8..fa0f12ca 100644
--- a/templates/docs/meter/homewizard_0.yaml
+++ b/templates/docs/meter/homewizard_0.yaml
@@ -8,3 +8,9 @@ render:
       template: homewizard
       usage: grid
       host: 192.0.2.2 # IP-Adresse oder Hostname
+  - usage: pv
+    default: |
+      type: template
+      template: homewizard
+      usage: pv
+      host: 192.0.2.2 # IP-Adresse oder Hostname
diff --git a/templates/docs/meter/mtec-eb-gen2_0.yaml b/templates/docs/meter/mtec-eb-gen2_0.yaml
index 5f76fb89..de33af61 100644
--- a/templates/docs/meter/mtec-eb-gen2_0.yaml
+++ b/templates/docs/meter/mtec-eb-gen2_0.yaml
@@ -10,14 +10,14 @@ render:
 
       # RS485 via adapter (Modbus RTU)
       modbus: rs485serial
-      id: 1
+      id: 247
       device: /dev/ttyUSB0 # USB-RS485 Adapter Adresse
       baudrate: 9600 # Prüfe die Geräteeinstellungen, typische Werte sind 9600, 19200, 38400, 57600, 115200
       comset: "8N1" # Kommunikationsparameter für den Adapter
 
       # RS485 via TCP/IP (Modbus RTU)
       modbus: rs485tcpip
-      id: 1
+      id: 247
       host: 192.0.2.2 # Hostname
       port: 502 # Port
   - usage: pv
@@ -28,14 +28,14 @@ render:
 
       # RS485 via adapter (Modbus RTU)
       modbus: rs485serial
-      id: 1
+      id: 247
       device: /dev/ttyUSB0 # USB-RS485 Adapter Adresse
       baudrate: 9600 # Prüfe die Geräteeinstellungen, typische Werte sind 9600, 19200, 38400, 57600, 115200
       comset: "8N1" # Kommunikationsparameter für den Adapter
 
       # RS485 via TCP/IP (Modbus RTU)
       modbus: rs485tcpip
-      id: 1
+      id: 247
       host: 192.0.2.2 # Hostname
       port: 502 # Port
   - usage: battery
@@ -46,13 +46,13 @@ render:
 
       # RS485 via adapter (Modbus RTU)
       modbus: rs485serial
-      id: 1
+      id: 247
       device: /dev/ttyUSB0 # USB-RS485 Adapter Adresse
       baudrate: 9600 # Prüfe die Geräteeinstellungen, typische Werte sind 9600, 19200, 38400, 57600, 115200
       comset: "8N1" # Kommunikationsparameter für den Adapter
 
       # RS485 via TCP/IP (Modbus RTU)
       modbus: rs485tcpip
-      id: 1
+      id: 247
       host: 192.0.2.2 # Hostname
       port: 502 # Port
diff --git a/templates/docs/meter/mtec-eb-gen3_0.yaml b/templates/docs/meter/mtec-eb-gen3_0.yaml
index 850d6ee2..05740fae 100644
--- a/templates/docs/meter/mtec-eb-gen3_0.yaml
+++ b/templates/docs/meter/mtec-eb-gen3_0.yaml
@@ -10,14 +10,14 @@ render:
 
       # RS485 via adapter (Modbus RTU)
       modbus: rs485serial
-      id: 1
+      id: 247
       device: /dev/ttyUSB0 # USB-RS485 Adapter Adresse
       baudrate: 9600 # Prüfe die Geräteeinstellungen, typische Werte sind 9600, 19200, 38400, 57600, 115200
       comset: "8N1" # Kommunikationsparameter für den Adapter
 
       # RS485 via TCP/IP (Modbus RTU)
       modbus: rs485tcpip
-      id: 1
+      id: 247
       host: 192.0.2.2 # Hostname
       port: 502 # Port
   - usage: pv
@@ -28,14 +28,14 @@ render:
 
       # RS485 via adapter (Modbus RTU)
       modbus: rs485serial
-      id: 1
+      id: 247
       device: /dev/ttyUSB0 # USB-RS485 Adapter Adresse
       baudrate: 9600 # Prüfe die Geräteeinstellungen, typische Werte sind 9600, 19200, 38400, 57600, 115200
       comset: "8N1" # Kommunikationsparameter für den Adapter
 
       # RS485 via TCP/IP (Modbus RTU)
       modbus: rs485tcpip
-      id: 1
+      id: 247
       host: 192.0.2.2 # Hostname
       port: 502 # Port
   - usage: battery
@@ -46,13 +46,13 @@ render:
 
       # RS485 via adapter (Modbus RTU)
       modbus: rs485serial
-      id: 1
+      id: 247
       device: /dev/ttyUSB0 # USB-RS485 Adapter Adresse
       baudrate: 9600 # Prüfe die Geräteeinstellungen, typische Werte sind 9600, 19200, 38400, 57600, 115200
       comset: "8N1" # Kommunikationsparameter für den Adapter
 
       # RS485 via TCP/IP (Modbus RTU)
       modbus: rs485tcpip
-      id: 1
+      id: 247
       host: 192.0.2.2 # Hostname
       port: 502 # Port
diff --git a/templates/evcc.io/brands.json b/templates/evcc.io/brands.json
index d6c17f10..9fddff3a 100644
--- a/templates/evcc.io/brands.json
+++ b/templates/evcc.io/brands.json
@@ -67,6 +67,7 @@
  "SmartPlugs": [
   "AVM",
   "Homematic / Homematic IP",
+  "HomeWizard",
   "myStrom",
   "Shelly",
   "Tasmota",
@@ -146,6 +147,7 @@
   "GoodWe",
   "Growatt",
   "Homematic IP",
+  "HomeWizard",
   "Hoymiles",
   "Huawei",
   "IGEN Tech",
diff --git a/util/oauth/helper.go b/util/oauth/helper.go
index c9692f98..15e16909 100644
--- a/util/oauth/helper.go
+++ b/util/oauth/helper.go
@@ -23,7 +23,9 @@ func Refresh(log *util.Logger, token *oauth2.Token, ts oauth2.TokenSource, optMa
 					log.ERROR.Printf("token refresh: %v, giving up", err)
 					return
 				}
+
 				log.ERROR.Printf("token refresh: %v", err)
+				continue
 			}
 
 			failed = 0
diff --git a/vehicle/volvo/connected/identity.go b/vehicle/volvo/connected/identity.go
index 57c22547..18a48aff 100644
--- a/vehicle/volvo/connected/identity.go
+++ b/vehicle/volvo/connected/identity.go
@@ -88,8 +88,10 @@ func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
 		return nil, err
 	}
 
-	var res oauth2.Token
-	err = v.DoJSON(req, &res)
+	var res oauth.Token
+	if err := v.DoJSON(req, &res); err != nil {
+		return nil, err
+	}
 
-	return &res, err
+	return (*oauth2.Token)(&res), err
 }


Test patch:
diff --git a/meter/homewizard/types_test.go b/meter/homewizard/types_test.go
new file mode 100644
index 00000000..f9e548e6
--- /dev/null
+++ b/meter/homewizard/types_test.go
@@ -0,0 +1,46 @@
+package homewizard
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+// Test ApiResponse
+func TestUnmarshalApiResponse(t *testing.T) {
+	{
+		var res ApiResponse
+
+		jsonstr := `{"product_type": "HWE-SKT","product_name": "P1 Meter","serial": "3c39e7aabbcc","firmware_version": "2.11","api_version": "v1"}`
+		assert.NoError(t, json.Unmarshal([]byte(jsonstr), &res))
+
+		assert.Equal(t, "HWE-SKT", res.ProductType)
+		assert.Equal(t, "v1", res.ApiVersion)
+	}
+}
+
+// Test StateResponse
+func TestUnmarshalStateResponse(t *testing.T) {
+	{
+		var res StateResponse
+
+		jsonstr := `{"power_on": true,"switch_lock": false,"brightness": 255}`
+		assert.NoError(t, json.Unmarshal([]byte(jsonstr), &res))
+
+		assert.Equal(t, true, res.PowerOn)
+	}
+}
+
+// Test DataResponse
+func TestUnmarshalDataResponse(t *testing.T) {
+	{
+		var res DataResponse
+
+		jsonstr := `{"wifi_ssid": "My Wi-Fi","wifi_strength": 100,"total_power_import_t1_kwh": 30.511,"total_power_export_t1_kwh": 85.951,"active_power_w": 543,"active_power_l1_w": 676}`
+		assert.NoError(t, json.Unmarshal([]byte(jsonstr), &res))
+
+		assert.Equal(t, float64(30.511), res.TotalPowerImportT1kWh+res.TotalPowerImportT2kWh+res.TotalPowerImportT3kWh+res.TotalPowerImportT4kWh)
+		assert.Equal(t, float64(543), res.ActivePowerW)
+	}
+}