Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions charger/homewizard.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,24 @@ func NewHomeWizardFromConfig(other map[string]any) (api.Charger, error) {
embed `mapstructure:",squash"`
URI string
Usage string
Phase int
StandbyPower float64
Cache time.Duration
}{
Phase: 1,
Cache: time.Second,
}

if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}

return NewHomeWizard(cc.embed, cc.URI, cc.Usage, cc.StandbyPower, cc.Cache)
return NewHomeWizard(cc.embed, cc.URI, cc.Usage, cc.Phase, cc.StandbyPower, cc.Cache)
}

// NewHomeWizard creates HomeWizard charger
func NewHomeWizard(embed embed, uri string, usage string, standbypower float64, cache time.Duration) (*HomeWizard, error) {
conn, err := homewizard.NewConnection(uri, usage, cache)
func NewHomeWizard(embed embed, uri string, usage string, phase int, standbypower float64, cache time.Duration) (*HomeWizard, error) {
conn, err := homewizard.NewConnection(uri, usage, phase, cache)
if err != nil {
return nil, err
}
Expand All @@ -53,7 +55,7 @@ func NewHomeWizard(embed embed, uri string, usage string, standbypower float64,
}

// Check compatible product type
if c.conn.ProductType != "HWE-SKT" {
if c.conn.ProductType != homewizard.ProductTypeSocket {
return nil, errors.New("unsupported product type: " + c.conn.ProductType)
}

Expand Down
8 changes: 5 additions & 3 deletions meter/homewizard.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,23 @@ func NewHomeWizardFromConfig(other map[string]any) (api.Meter, error) {
cc := struct {
URI string
Usage string
Phase int
Cache time.Duration
}{
Phase: 1,
Cache: time.Second,
}

if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}

return NewHomeWizard(cc.URI, cc.Usage, cc.Cache)
return NewHomeWizard(cc.URI, cc.Usage, cc.Phase, cc.Cache)
}

// NewHomeWizard creates HomeWizard meter
func NewHomeWizard(uri string, usage string, cache time.Duration) (*HomeWizard, error) {
conn, err := homewizard.NewConnection(uri, usage, cache)
func NewHomeWizard(uri string, usage string, phase int, cache time.Duration) (*HomeWizard, error) {
conn, err := homewizard.NewConnection(uri, usage, phase, cache)
if err != nil {
return nil, err
}
Expand Down
73 changes: 67 additions & 6 deletions meter/homewizard/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,43 @@ import (
"github.com/evcc-io/evcc/util/transport"
)

// Product type constants
const (
ProductTypeKWH1 = "HWE-KWH1" // Single-phase kWh meter
ProductTypeKWH3 = "HWE-KWH3" // Three-phase kWh meter
ProductTypeSDM230 = "SDM230-wifi" // Single-phase kWh meter
ProductTypeSDM630 = "SDM630-wifi" // Three-phase kWh meter
ProductTypeSocket = "HWE-SKT" // Smart socket
ProductTypeP1 = "HWE-P1" // P1 meter
)

// Connection is the homewizard connection
type Connection struct {
*request.Helper
uri string
usage string
phase int
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entire single phase mapping is a bad idea, please remove.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But why is it a bad idea? Load limits apply to each phase and so the measurements should be counted to the right phase.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But why is it a bad idea? Load limits apply to each phase and so the measurements should be counted to the right phase.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We‘re not doing this for any device

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove or open fresh pr without

ProductType string
dataG util.Cacheable[DataResponse]
stateG util.Cacheable[StateResponse]
}

// NewConnection creates a homewizard connection
func NewConnection(uri string, usage string, cache time.Duration) (*Connection, error) {
func NewConnection(uri string, usage string, phase int, cache time.Duration) (*Connection, error) {
if uri == "" {
return nil, errors.New("missing uri")
}

if phase < 1 || phase > 3 {
return nil, errors.New("phase must be between 1 and 3")
}

log := util.NewLogger("homewizard")
c := &Connection{
Helper: request.NewHelper(log),
uri: fmt.Sprintf("%s/api", util.DefaultScheme(strings.TrimRight(uri, "/"), "http")),
usage: usage,
phase: phase,
}

c.Client.Transport = request.NewTripper(log, transport.Insecure())
Expand Down Expand Up @@ -64,6 +80,30 @@ func NewConnection(uri string, usage string, cache time.Duration) (*Connection,
return c, nil
}

// isSinglePhase returns true if the product type is a single-phase meter
func isSinglePhase(productType string) bool {
switch productType {
case ProductTypeKWH1, ProductTypeSDM230:
return true
default:
return false
}
}

// mapValueToPhase maps a single-phase value to the specified phase (L1, L2, or L3)
func mapValueToPhase(value float64, phase int) (float64, float64, float64) {
switch phase {
case 1:
return value, 0, 0
case 2:
return 0, value, 0
case 3:
return 0, 0, value
default:
return value, 0, 0 // fallback to L1
}
}

// Enable implements the api.Charger interface
func (c *Connection) Enable(enable bool) error {
var res StateResponse
Expand Down Expand Up @@ -101,7 +141,7 @@ func (c *Connection) Enabled() (bool, error) {
// CurrentPower implements the api.Meter interface
func (c *Connection) CurrentPower() (float64, error) {
res, err := c.dataG.Get()
if c.usage == "pv" {
if c.usage == "pv" || c.usage == "battery" {
return -res.ActivePowerW, err
}
return res.ActivePowerW, err
Expand All @@ -110,16 +150,29 @@ func (c *Connection) CurrentPower() (float64, error) {
// TotalEnergy implements the api.MeterEnergy interface
func (c *Connection) TotalEnergy() (float64, error) {
res, err := c.dataG.Get()
if c.usage == "pv" {
return res.TotalPowerExportT1kWh + res.TotalPowerExportT2kWh + res.TotalPowerExportT3kWh + res.TotalPowerExportT4kWh, err
if c.usage == "pv" || c.usage == "battery" {
return res.TotalPowerExportkWh, err
}
return res.TotalPowerImportT1kWh + res.TotalPowerImportT2kWh + res.TotalPowerImportT3kWh + res.TotalPowerImportT4kWh, err
return res.TotalPowerImportkWh, err
}

// Currents implements the api.PhaseCurrents interface
func (c *Connection) Currents() (float64, float64, float64, error) {
res, err := c.dataG.Get()
if c.usage == "pv" {

// Single-phase meters only have one current reading
if isSinglePhase(c.ProductType) {
current := res.ActiveCurrentA
if c.usage == "pv" || c.usage == "battery" {
current = -current
}

l1, l2, l3 := mapValueToPhase(current, c.phase)
Comment on lines +185 to +191
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (bug_risk): Clarify behavior for single-phase devices where ActiveCurrentL1A is also provided.

DataResponse for single-phase meters also exposes ActiveCurrentL1A. If a device reports both ActiveCurrentA and ActiveCurrentL1A with different semantics (e.g. summed/signed vs. phase-specific), this mapping could diverge from how three-phase devices use ActiveCurrentLxA. Consider deriving single-phase currents from the L1/L2/L3 fields for consistency, or explicitly assert/validate that ActiveCurrentA matches the L1 reading for these product types.

return l1, l2, l3, err
}

// Three-phase meters have separate current readings per phase
if c.usage == "pv" || c.usage == "battery" {
return -res.ActiveCurrentL1A, -res.ActiveCurrentL2A, -res.ActiveCurrentL3A, err
}
return res.ActiveCurrentL1A, res.ActiveCurrentL2A, res.ActiveCurrentL3A, err
Expand All @@ -128,5 +181,13 @@ func (c *Connection) Currents() (float64, float64, float64, error) {
// Voltages implements the api.PhaseVoltages interface
func (c *Connection) Voltages() (float64, float64, float64, error) {
res, err := c.dataG.Get()

// Single-phase meters only have one voltage reading
if isSinglePhase(c.ProductType) {
l1, l2, l3 := mapValueToPhase(res.ActiveVoltageV, c.phase)
return l1, l2, l3, err
}

// Three-phase meters have separate voltage readings per phase
return res.ActiveVoltageL1V, res.ActiveVoltageL2V, res.ActiveVoltageL3V, err
}
26 changes: 11 additions & 15 deletions meter/homewizard/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,15 @@ type StateResponse struct {
// 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"`
TotalPowerExportT1kWh float64 `json:"total_power_export_t1_kwh"`
TotalPowerExportT2kWh float64 `json:"total_power_export_t2_kwh"`
TotalPowerExportT3kWh float64 `json:"total_power_export_t3_kwh"`
TotalPowerExportT4kWh float64 `json:"total_power_export_t4_kwh"`
ActiveCurrentL1A float64 `json:"active_current_l1_a"`
ActiveCurrentL2A float64 `json:"active_current_l2_a"`
ActiveCurrentL3A float64 `json:"active_current_l3_a"`
ActiveVoltageL1V float64 `json:"active_voltage_l1_v"`
ActiveVoltageL2V float64 `json:"active_voltage_l2_v"`
ActiveVoltageL3V float64 `json:"active_voltage_l3_v"`
ActivePowerW float64 `json:"active_power_w"`
TotalPowerImportkWh float64 `json:"total_power_import_kwh"`
TotalPowerExportkWh float64 `json:"total_power_export_kwh"`
ActiveCurrentA float64 `json:"active_current_a"`
ActiveCurrentL1A float64 `json:"active_current_l1_a"`
ActiveCurrentL2A float64 `json:"active_current_l2_a"`
ActiveCurrentL3A float64 `json:"active_current_l3_a"`
ActiveVoltageV float64 `json:"active_voltage_v"`
ActiveVoltageL1V float64 `json:"active_voltage_l1_v"`
ActiveVoltageL2V float64 `json:"active_voltage_l2_v"`
ActiveVoltageL3V float64 `json:"active_voltage_l3_v"`
Comment on lines +23 to +32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (bug_risk): Switching from tariff-based totals to aggregate import/export fields may affect compatibility with existing devices/firmware.

This change assumes total_power_import_kwh and total_power_export_kwh exist on all supported HomeWizard devices/firmware. Please verify that older KWH/P1 versions don’t expose only TotalPowerImportT{1..4}kWh / TotalPowerExportT{1..4}kWh. If some do, consider preferring the aggregate fields when available and falling back to summing the tariff fields when aggregates are missing or zero.

}
10 changes: 5 additions & 5 deletions meter/homewizard/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ func TestUnmarshalKwhDataResponse(t *testing.T) {
{
var res DataResponse
// https://www.homewizard.com/shop/wi-fi-kwh-meter-1-phase/
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": 28,"active_power_l2_w": 0,"active_power_l3_w": -181,"active_voltage_l1_v": 235.4,"active_voltage_l2_v": 235.8,"active_voltage_l3_v": 236.1,"active_current_l1_a": 1.19,"active_current_l2_a": 0.37,"active_current_l3_a": -0.93}`
jsonstr := `{"wifi_ssid": "My Wi-Fi","wifi_strength": 100,"total_power_import_kwh": 30.511,"total_power_export_kwh": 85.951,"total_power_import_t1_kwh": 30.511,"total_power_export_t1_kwh": 85.951,"active_power_w": 543,"active_power_l1_w": 28,"active_power_l2_w": 0,"active_power_l3_w": -181,"active_voltage_l1_v": 235.4,"active_voltage_l2_v": 235.8,"active_voltage_l3_v": 236.1,"active_current_l1_a": 1.19,"active_current_l2_a": 0.37,"active_current_l3_a": -0.93}`
require.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(85.951), res.TotalPowerExportT1kWh+res.TotalPowerExportT2kWh+res.TotalPowerExportT3kWh+res.TotalPowerExportT4kWh)
assert.Equal(t, float64(30.511), res.TotalPowerImportkWh)
assert.Equal(t, float64(85.951), res.TotalPowerExportkWh)
Comment on lines +44 to +45
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Assert new current/voltage aggregate fields in kWh data response

Please also extend this test to assert the ActiveCurrentA and ActiveVoltageV values when present in the JSON, so changes to their JSON tags or types are caught by tests.

Suggested implementation:

		jsonstr := `{"wifi_ssid":"redacted","wifi_strength":78,"smr_version":50,"meter_model":"Landis + Gyr","unique_id":"redacted","active_tariff":2,"total_power_import_kwh":18664.997,"total_power_import_t1_kwh":10909.724,"total_power_import_t2_kwh":7755.273,"total_power_export_kwh":13823.608,"total_power_export_t1_kwh":4243.981,"total_power_export_t2_kwh":9579.627,"active_power_w":203.000,"active_power_l1_w":-21.000,"active_power_l2_w":57.000,"active_power_l3_w":168.000,"active_voltage_v":226.000,"active_voltage_l1_v":228.000,"active_voltage_l2_v":226.000,"active_voltage_l3_v":225.000,"active_current_a":1.091,"active_current_l1_a":-0.092,"active_current_l2_a":0.252,"active_current_l3_a":0.747,"voltage_sag_l1_count":12.000,"voltage_sag_l2_count":12.000,"voltage_sag_l3_count":19.000,"voltage_swell_l1_count":5055.000,"voltage_swell_l2_count":1950.000,"voltage_swell_l3_count":0.000,"any_power_fail_count":12.000,"long_power_fail_count":2.000,"total_gas_m3":5175.363,"gas_timestamp":241106093006,"gas_unique_id":"redacted","external":[{"unique_id":"redacted","type":"gas_meter","timestamp":241106093006,"value":5175.363,"unit":"m3"}]}`
		assert.Equal(t, float64(18664.997), res.TotalPowerImportkWh)
		assert.Equal(t, float64(13823.608), res.TotalPowerExportkWh)
		assert.Equal(t, float64(203), res.ActivePowerW)
		assert.Equal(t, float64(226), res.ActiveVoltageV)
		assert.Equal(t, float64(1.091), res.ActiveCurrentA)
		assert.Equal(t, float64(228), res.ActiveVoltageL1V)

assert.Equal(t, float64(543), res.ActivePowerW)
assert.Equal(t, float64(235.4), res.ActiveVoltageL1V)
assert.Equal(t, float64(235.8), res.ActiveVoltageL2V)
Expand All @@ -61,8 +61,8 @@ func TestUnmarshalP1DataResponse(t *testing.T) {
jsonstr := `{"wifi_ssid":"redacted","wifi_strength":78,"smr_version":50,"meter_model":"Landis + Gyr","unique_id":"redacted","active_tariff":2,"total_power_import_kwh":18664.997,"total_power_import_t1_kwh":10909.724,"total_power_import_t2_kwh":7755.273,"total_power_export_kwh":13823.608,"total_power_export_t1_kwh":4243.981,"total_power_export_t2_kwh":9579.627,"active_power_w":203.000,"active_power_l1_w":-21.000,"active_power_l2_w":57.000,"active_power_l3_w":168.000,"active_voltage_l1_v":228.000,"active_voltage_l2_v":226.000,"active_voltage_l3_v":225.000,"active_current_a":1.091,"active_current_l1_a":-0.092,"active_current_l2_a":0.252,"active_current_l3_a":0.747,"voltage_sag_l1_count":12.000,"voltage_sag_l2_count":12.000,"voltage_sag_l3_count":19.000,"voltage_swell_l1_count":5055.000,"voltage_swell_l2_count":1950.000,"voltage_swell_l3_count":0.000,"any_power_fail_count":12.000,"long_power_fail_count":2.000,"total_gas_m3":5175.363,"gas_timestamp":241106093006,"gas_unique_id":"redacted","external":[{"unique_id":"redacted","type":"gas_meter","timestamp":241106093006,"value":5175.363,"unit":"m3"}]}`
require.NoError(t, json.Unmarshal([]byte(jsonstr), &res))

assert.Equal(t, float64(18664.997), res.TotalPowerImportT1kWh+res.TotalPowerImportT2kWh+res.TotalPowerImportT3kWh+res.TotalPowerImportT4kWh)
assert.Equal(t, float64(13823.608), res.TotalPowerExportT1kWh+res.TotalPowerExportT2kWh+res.TotalPowerExportT3kWh+res.TotalPowerExportT4kWh)
assert.Equal(t, float64(18664.997), res.TotalPowerImportkWh)
assert.Equal(t, float64(13823.608), res.TotalPowerExportkWh)
assert.Equal(t, float64(203), res.ActivePowerW)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add tests for NewConnection phase validation and default phase behavior

Since NewConnection now validates that phase is in [1,3], add tests that:

  • Call NewConnection with invalid phases (e.g. 0, 4, and a negative) and assert it returns an error mentioning the valid range.
  • Use the default Phase: 1 in charger and meter configs, ensure the resulting Connection is valid, and confirm the internal phase is 1.

This will guard against regressions in phase validation and defaulting during future refactors.

assert.Equal(t, float64(228), res.ActiveVoltageL1V)
assert.Equal(t, float64(226), res.ActiveVoltageL2V)
Expand Down
14 changes: 14 additions & 0 deletions templates/definition/charger/homewizard.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
template: homewizard
products:
- brand: HomeWizard
description:
en: Smart Socket (HWE-SKT)
de: Smart-Steckdose (HWE-SKT)
group: switchsockets
params:
- name: host
- name: phase
description:
en: Installation phase
de: Installationsphase
default: 1
choice: [1, 2, 3]
advanced: true
help:
en: Phase on which socket is installed (L1=1, L2=2, L3=3)
de: Phase an der die Steckdose installiert ist (L1=1, L2=2, L3=3)
- preset: switchsocket
render: |
type: homewizard
uri: http://{{ .host }}
phase: {{ .phase }}
15 changes: 14 additions & 1 deletion templates/definition/meter/homewizard-kwh.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,24 @@ products:
- brand: HomeWizard
description:
generic: kWh Meter
en: kWh Meter (HWE-KWH1, HWE-KWH3, SDM230-wifi, SDM630-wifi)
de: kWh-Zähler (HWE-KWH1, HWE-KWH3, SDM230-wifi, SDM630-wifi)
params:
- name: usage
choice: ["pv", "charge"]
choice: ["pv", "charge", "battery"]
- name: host
- name: phase
default: 1
choice: [1, 2, 3]
advanced: true
description:
en: Installation phase
de: Installationsphase
help:
en: Phase on which the single-phase meter is installed (L1=1, L2=2, L3=3). Only relevant for HWE-KWH1 and SDM230-wifi.
de: Phase an der der einphasige Zähler installiert ist (L1=1, L2=2, L3=3). Nur relevant für HWE-KWH1 und SDM230-wifi.
render: |
type: homewizard
uri: http://{{ .host }}
usage: {{ .usage }}
phase: {{ .phase }}
Loading