Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
dd4cdb6
Add HomeWizard Plugin Battery support
mluiten Nov 29, 2025
3f8e727
Improve validation of configuration and add template definition
mluiten Nov 29, 2025
9702338
Add reasonable time-out to requests
mluiten Nov 30, 2025
0595523
Remove option to provide host in token script
mluiten Nov 30, 2025
d9fd689
Add some cache, move some files around, simplify
mluiten Nov 30, 2025
f823682
Add our validation error to the template test
mluiten Nov 30, 2025
18da9ce
Remove development logs
mluiten Nov 30, 2025
efb1133
Refactor to be generic homewizard v2 implementation.
mluiten Nov 30, 2025
f0fd417
Rename to use homewizard-v2 prefix for templates
mluiten Nov 30, 2025
090cb47
Add optional usage to kWh meter
mluiten Nov 30, 2025
8221374
Fix cli after some merge conflicts
mluiten Nov 30, 2025
5a3c1fc
Fix linting issues
mluiten Nov 30, 2025
0781da3
Move implementation details to external module
mluiten Nov 30, 2025
5fd5b1d
Fix usage of meters for UI integration
mluiten Nov 30, 2025
b3691b7
Add mode to pair single device
mluiten Nov 30, 2025
0557c53
Correctly name templates
mluiten Nov 30, 2025
5c3cc11
Manually add to CODEOWNERS, if needed?
mluiten Nov 30, 2025
cfca7ea
Fix go.sum
mluiten Nov 30, 2025
840133c
Simplify configuration by finding the (only) HW P1 meter in the confi…
mluiten Dec 1, 2025
a2feb45
Add support for both 1-phase as 3-phase meters
mluiten Dec 1, 2025
1848fb6
Merge branch 'master' into feat/homewizard-plugin-battery
mluiten Dec 1, 2025
23699c9
Consolidate to single meter type with different implementations
mluiten Dec 1, 2025
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
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@
/templates/definition/meter/homematic.yaml @thierolm
/templates/definition/meter/homewizard-kwh.yaml @Rido @thierolm
/templates/definition/meter/homewizard-p1.yaml @thierolm
/templates/definition/meter/homewizard-v2-p1.yaml @mluiten
/templates/definition/meter/homewizard-v2-kwh.yaml @mluiten
/templates/definition/meter/homewizard-v2-battery.yaml @mluiten
/templates/definition/meter/hoymiles-ahoydtu.yaml @Starquake
/templates/definition/meter/hoymiles-opendtu.yaml @Starquake @xerion3800
/templates/definition/meter/huawei-emma.yaml @Mungg1818 @VolkerK62
Expand Down
41 changes: 41 additions & 0 deletions cmd/token_homewizard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package cmd

import (
"github.com/mluiten/evcc-homewizard-v2/pairing"
"github.com/spf13/cobra"
)

var homeWizardCmd = &cobra.Command{
Use: "homewizard",
Short: "Pair with HomeWizard devices using v2 API",
Run: runHomeWizardToken,
}

func init() {
tokenCmd.AddCommand(homeWizardCmd)
homeWizardCmd.Flags().StringP("name", "n", "evcc", "Product name for pairing")
homeWizardCmd.Flags().Int("timeout", 10, "Discovery timeout in seconds")
homeWizardCmd.Flags().String("host", "", "Device host/IP (skips discovery, pairs specific device)")
}

func runHomeWizardToken(cmd *cobra.Command, args []string) {
// Parse log levels to enable debug/trace logging if requested
parseLogLevels()

name := cmd.Flag("name").Value.String()
timeout, _ := cmd.Flags().GetInt("timeout")
host, _ := cmd.Flags().GetString("host")

// If host is specified, skip discovery and pair directly
if host != "" {
if err := pairing.PairSingleDevice(host, name); err != nil {
log.FATAL.Fatal(err)
}
return
}

// Otherwise run full discovery + pairing flow
if err := pairing.DiscoverAndPairDevices(name, timeout); err != nil {
log.FATAL.Fatal(err)
}
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ require (
github.com/lunixbochs/struc v0.0.0-20241101090106-8d528fa2c543
github.com/mabunixda/wattpilot v1.8.5
github.com/mitchellh/go-homedir v1.1.0
github.com/mluiten/evcc-homewizard-v2 v0.4.0
github.com/modelcontextprotocol/go-sdk v1.1.0
github.com/muka/go-bluetooth v0.0.0-20240701044517-04c4f09c514e
github.com/mxschmitt/golang-combinations v1.2.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,8 @@ github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:F
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/mluiten/evcc-homewizard-v2 v0.4.0 h1:7saRrzrRfHHb5o8u/tu/fPwepv/vCRxVjIK5A7A6VkA=
github.com/mluiten/evcc-homewizard-v2 v0.4.0/go.mod h1:0gWZsWfzmBhqoG0SEbHdx6ohOE7EyRl+2ZQHos+y/yM=
github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA=
github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
Expand Down
44 changes: 44 additions & 0 deletions meter/homewizard-v2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package meter

import (
"fmt"

"github.com/evcc-io/evcc/api"
homewizard "github.com/evcc-io/evcc/meter/homewizard-v2"
"github.com/evcc-io/evcc/util"
)

func init() {
registry.Add("homewizard-v2", NewHomeWizardV2FromConfig)
}

// NewHomeWizardV2FromConfig creates a HomeWizard meter from configuration
func NewHomeWizardV2FromConfig(other map[string]any) (api.Meter, error) {
cc := homewizard.Config{
Timeout: homewizard.DefaultTimeout,
}

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

if cc.Host == "" || cc.Token == "" {
return nil, fmt.Errorf("missing host or token - run 'evcc token homewizard'")
}

if cc.Usage == "" {
return nil, fmt.Errorf("missing required parameter 'usage' (must be one of: grid, pv, charge, battery)")
}

// Dispatch based on usage
switch cc.Usage {
case "grid":
return homewizard.NewHomeWizardP1FromConfig(cc, other)
case "pv", "charge":
return homewizard.NewHomeWizardKWHFromConfig(cc, other)
case "battery":
return homewizard.NewHomeWizardBatteryFromConfig(cc, other)
default:
return nil, fmt.Errorf("invalid usage '%s': must be one of: grid, pv, charge, battery", cc.Usage)
}
}
184 changes: 184 additions & 0 deletions meter/homewizard-v2/battery.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package homewizard

import (
"fmt"
"sync"

"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/mluiten/evcc-homewizard-v2/device"
)

// HomeWizardBattery implements the api.Meter interface for battery devices
type HomeWizardBattery struct {
log *util.Logger
device *device.BatteryDevice
controller *device.P1MeterDevice
controllerOnce sync.Once
capacity float64
maxCharge float64 // Maximum charge power in W
maxDischarge float64 // Maximum discharge power in W
}

func NewHomeWizardBatteryFromConfig(common Config, other map[string]any) (api.Meter, error) {
cc := struct {
Capacity float64
MaxCharge float64
MaxDischarge float64
}{
MaxCharge: device.DefaultMaxCharge,
MaxDischarge: device.DefaultMaxDischarge,
}

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

m := &HomeWizardBattery{
log: util.NewLogger("homewizard-battery"),
device: device.NewBatteryDevice(common.Host, common.Token, common.Timeout),
capacity: cc.Capacity,
maxCharge: cc.MaxCharge,
maxDischarge: cc.MaxDischarge,
}

// Start device connection and wait for it to succeed
if err := m.device.StartAndWait(common.Timeout); err != nil {
return nil, err
}

m.log.INFO.Printf("configured battery at %s", common.Host)

return m, nil
}

// getController resolves the controller P1 meter (lazy initialization to avoid timing issues)
func (m *HomeWizardBattery) getController() (*device.P1MeterDevice, error) {
var err error

m.controllerOnce.Do(func() {
// Look up controller meter from registry
dev, lookupErr := findP1Device(config.Meters().Devices())
if lookupErr != nil {
err = fmt.Errorf("controller meter not found: %w", lookupErr)
return
}

controllerMeter := dev.Instance()

// Controller must be a HomeWizardP1 meter
controllerP1, ok := controllerMeter.(*HomeWizardP1)
if !ok {
err = fmt.Errorf("expected meter '%s' to be a homewizard-v2 P1 meter (got %T)", dev.Config().Name, controllerMeter)
return
}

// Store P1MeterDevice for battery control
m.controller = controllerP1.device
m.log.DEBUG.Printf("resolved controller: %s", m.controller.Host())
})

if err != nil {
return nil, err
}

if m.controller == nil {
return nil, fmt.Errorf("controller not resolved")
}

return m.controller, nil
}

func findP1Device[T any](in []config.Device[T]) (config.Device[T], error) {
for _, d := range in {
if d.Config().Type == "homewizard-v2" {
// Check if instance is a P1 meter by converting to interface{} first
instance := d.Instance()
if _, ok := any(instance).(*HomeWizardP1); ok {
return d, nil
}
}
}

return nil, fmt.Errorf("cannot find any HomeWizard P1 devices; configure one before adding the battery")
}

var _ api.Meter = (*HomeWizardBattery)(nil)

// CurrentPower implements the api.Meter interface
func (m *HomeWizardBattery) CurrentPower() (float64, error) {
return m.device.GetPower()
}

var _ api.MeterEnergy = (*HomeWizardBattery)(nil)

// TotalEnergy implements the api.MeterEnergy interface
func (m *HomeWizardBattery) TotalEnergy() (float64, error) {
return m.device.GetTotalEnergy()
}

var _ api.Battery = (*HomeWizardBattery)(nil)

// Soc implements the api.Battery interface
func (m *HomeWizardBattery) Soc() (float64, error) {
return m.device.GetSoc()
}

var _ api.BatteryCapacity = (*HomeWizardBattery)(nil)

// Capacity implements the api.BatteryCapacity interface
func (m *HomeWizardBattery) Capacity() float64 {
// If user provided capacity, use that
if m.capacity > 0 {
return m.capacity
}

// Use default HWE-BAT capacity from device
return m.device.DefaultCapacity()
}

var _ api.BatteryController = (*HomeWizardBattery)(nil)

// SetBatteryMode implements the api.BatteryController interface
func (m *HomeWizardBattery) SetBatteryMode(mode api.BatteryMode) error {
m.log.INFO.Printf("setting battery mode to: %v", mode)

// Get controller P1 meter (lazy resolution)
controller, err := m.getController()
if err != nil {
m.log.ERROR.Printf("failed to get controller: %v", err)
return err
}

// Convert evcc mode to HomeWizard mode
var hwMode string
switch mode {
case api.BatteryNormal:
hwMode = "zero"
case api.BatteryCharge:
hwMode = "to_full"
case api.BatteryHold:
hwMode = "standby"
default:
return fmt.Errorf("unsupported battery mode: %v", mode)
}

m.log.INFO.Printf("converted to HomeWizard mode: %s", hwMode)

// Set battery mode via controller P1 meter's wrapper method
if err := controller.SetBatteryMode(hwMode); err != nil {
m.log.ERROR.Printf("failed to set battery mode: %v", err)
return err
}

m.log.INFO.Printf("battery mode set successfully to: %s", hwMode)
return nil
}

var _ api.BatteryPowerLimiter = (*HomeWizardBattery)(nil)

// GetPowerLimits implements the api.BatteryPowerLimiter interface
func (m *HomeWizardBattery) GetPowerLimits() (float64, float64) {
return m.maxCharge, m.maxDischarge
}
33 changes: 33 additions & 0 deletions meter/homewizard-v2/common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package homewizard

import (
"time"

"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/mluiten/evcc-homewizard-v2/device"
)

// DefaultTimeout is the default connection timeout
const DefaultTimeout = device.DefaultTimeout

// Config holds common configuration for all HomeWizard meters
type Config struct {
Host string
Token string
Usage string
Timeout time.Duration
}

// HomeWizardMeter is a minimal base that only stores common fields
type HomeWizardMeter struct {
log *util.Logger
phases int // 1 or 3
}

var _ api.PhaseGetter = (*HomeWizardMeter)(nil)

// GetPhases implements the api.PhaseGetter interface
func (m *HomeWizardMeter) GetPhases() (int, error) {
return m.phases, nil
}
Loading
Loading