evcc-io/charger/vaillant.go
2025-09-10 12:37:34 +02:00

260 lines
6.6 KiB
Go

package charger
// LICENSE
// Copyright (c) evcc.io (andig, naltatis, premultiply)
// This module is NOT covered by the MIT license. All rights reserved.
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import (
"context"
"fmt"
"reflect"
"strings"
"time"
"github.com/WulfgarW/sensonet"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/samber/lo"
"golang.org/x/oauth2"
)
func init() {
registry.AddCtx("vaillant", NewVaillantFromConfig)
}
type Vaillant struct {
*SgReady
log *util.Logger
conn *sensonet.Connection
systemId string
}
//go:generate go tool decorate -f decorateVaillant -b *Vaillant -r api.Charger -t "api.Meter,CurrentPower,func() (float64, error)" -t "api.Battery,Soc,func() (float64, error)"
// NewVaillantFromConfig creates an Vaillant configurable charger from generic config
func NewVaillantFromConfig(ctx context.Context, other map[string]interface{}) (api.Charger, error) {
cc := struct {
embed `mapstructure:",squash"`
User, Password string
Realm string
HeatingZone int
HeatingSetpoint float32
Cache time.Duration
}{
embed: embed{
Icon_: "heatpump",
Features_: []api.Feature{api.Heating, api.IntegratedDevice},
},
Realm: sensonet.REALM_GERMANY,
Cache: time.Minute,
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
if cc.User == "" || cc.Password == "" {
return nil, api.ErrMissingCredentials
}
log := util.NewLogger("vaillant").Redact(cc.User, cc.Password)
logCtx := context.WithValue(ctx, oauth2.HTTPClient, request.NewClient(log))
oc := sensonet.Oauth2ConfigForRealm(cc.Realm)
token, err := oc.PasswordCredentialsToken(logCtx, cc.User, cc.Password)
if err != nil {
return nil, err
}
conn, err := sensonet.NewConnection(oc.TokenSource(logCtx, token), sensonet.WithHttpClient(request.NewClient(log)))
if err != nil {
return nil, err
}
homes, err := conn.GetHomes()
if err != nil {
return nil, err
}
systemId := homes[0].SystemID
heating := cc.HeatingSetpoint > 0
wwCancel := func() {}
set := func(mode int64) error {
switch mode {
case Normal:
if heating {
return conn.StopZoneQuickVeto(systemId, cc.HeatingZone)
}
wwCancel()
return conn.StopHotWaterBoost(systemId, sensonet.HOTWATERINDEX_DEFAULT)
case Boost:
if heating {
return conn.StartZoneQuickVeto(systemId, cc.HeatingZone, cc.HeatingSetpoint, 4) // hours
}
if err := conn.StartHotWaterBoost(systemId, sensonet.HOTWATERINDEX_DEFAULT); err != nil {
return err
}
var wwCtx context.Context
wwCtx, wwCancel = context.WithCancel(ctx)
// re-boost every 15m
go func() {
for {
select {
case <-wwCtx.Done():
return
case <-time.After(15 * time.Minute):
if err := conn.StartHotWaterBoost(systemId, sensonet.HOTWATERINDEX_DEFAULT); err != nil {
log.ERROR.Println("hot water boost:", err)
}
}
}
}()
return nil
default:
return api.ErrNotAvailable
}
}
sgr, err := NewSgReady(ctx, &cc.embed, set, nil, nil)
if err != nil {
return nil, err
}
res := &Vaillant{
log: log,
conn: conn,
systemId: systemId,
SgReady: sgr,
}
var power func() (float64, error)
if devices, _ := conn.GetMpcData(systemId); len(devices) > 0 {
power = util.Cached(func() (float64, error) {
res, err := conn.GetMpcData(systemId)
return lo.SumBy(res, func(d sensonet.MpcDevice) float64 {
return d.CurrentPower
}), err
}, cc.Cache)
}
heatingTemp := func(zz []sensonet.StateZone) float64 {
z, _ := lo.Find(zz, func(z sensonet.StateZone) bool {
return z.Index == cc.HeatingZone
})
return z.CurrentRoomTemperature
}
var heatingTempSensor bool
if heating {
system, err := conn.GetSystem(systemId)
if err != nil {
return nil, err
}
heatingTempSensor = heatingTemp(system.State.Zones) > 0
}
var temp func() (float64, error)
if !heating || heatingTempSensor {
temp = util.Cached(func() (float64, error) {
system, err := conn.GetSystem(systemId)
if err != nil {
return 0, err
}
switch {
case heatingTempSensor:
if res := heatingTemp(system.State.Zones); res > 0 {
return res, nil
}
return 0, api.ErrNotAvailable
case len(system.State.Dhw) > 0:
return system.State.Dhw[0].CurrentDhwTemperature, nil
case len(system.State.DomesticHotWater) > 0:
return system.State.DomesticHotWater[0].CurrentDomesticHotWaterTemperature, nil
default:
return 0, api.ErrNotAvailable
}
}, cc.Cache)
}
return decorateVaillant(res, power, temp), nil
}
func (v *Vaillant) print(chapter int, prefix string, zz ...any) {
var i int
for _, z := range zz {
rt := reflect.TypeOf(z)
rv := reflect.ValueOf(z)
if rt.Kind() == reflect.Slice && rv.Len() == 0 {
continue
}
i++
typ := strings.TrimPrefix(strings.TrimPrefix(fmt.Sprintf("%T", z), "[]sensonet."), prefix)
fmt.Println()
fmt.Printf("%d.%d. %s\n", chapter, i+1, typ)
if rt.Kind() == reflect.Slice {
for j := range rv.Len() {
fmt.Printf("%d.%d.%d. %s %d\n", chapter, i+1, j+1, typ, j)
fmt.Printf("%+v\n", rv.Index(j))
}
} else {
fmt.Printf("%+v\n", z)
}
}
}
func (v *Vaillant) Diagnose() {
sys, err := v.conn.GetSystem(v.systemId)
if err != nil {
v.log.ERROR.Println(err)
return
}
fmt.Println()
fmt.Println("1. State")
fmt.Println()
fmt.Println("1.1. System")
fmt.Printf("%+v\n", sys.State.System)
v.print(1, "State", sys.State.Zones, sys.State.Circuits, sys.State.Dhw, sys.State.DomesticHotWater)
fmt.Println()
fmt.Println("2. Properties")
fmt.Println()
fmt.Println("2.1. System")
fmt.Printf("%+v\n", sys.Properties.System)
v.print(2, "Properties", sys.Properties.Zones, sys.Properties.Circuits, sys.Properties.Dhw, sys.Properties.DomesticHotWater)
fmt.Println()
fmt.Println("3. Configuration")
fmt.Println()
fmt.Println("3.1. System")
fmt.Printf("%+v\n", sys.Configuration.System)
v.print(3, "Configuration", sys.Configuration.Zones, sys.Configuration.Circuits, sys.Configuration.Dhw, sys.Configuration.DomesticHotWater)
}