evcc-io/charger/vaillant.go

312 lines
7.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"
"errors"
"fmt"
"reflect"
"strings"
"time"
"github.com/WulfgarW/sensonet"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/api/implement"
"github.com/evcc-io/evcc/charger/vaillant"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/samber/lo"
)
func init() {
registry.AddCtx("vaillant", NewVaillantFromConfig)
}
type Vaillant struct {
*SgReady
log *util.Logger
conn *sensonet.Connection
lp loadpoint.API
systemId string
}
// NewVaillantFromConfig creates an Vaillant configurable charger from generic config
func NewVaillantFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) {
cc := struct {
embed `mapstructure:",squash"`
User, Password string
Realm string
System string
HeatingZone int
HeatingSetpoint float32
Hysteresis float64
Reboost time.Duration
Cache time.Duration
}{
embed: embed{
Icon_: "heatpump",
Features_: []api.Feature{api.Continuous, api.Heating, api.IntegratedDevice, api.SwitchDevice},
},
Realm: sensonet.REALM_GERMANY,
Reboost: 15 * time.Minute,
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)
oc := sensonet.Oauth2ConfigForRealm(cc.Realm)
ts, err := vaillant.Identity(log, oc, cc.Realm, cc.User, cc.Password)
if err != nil {
return nil, err
}
conn, err := sensonet.NewConnection(ts, sensonet.WithHttpClient(request.NewClient(log)))
if err != nil {
return nil, err
}
home, err := ensureEx("home", cc.System, func() ([]sensonet.Home, error) {
return conn.GetHomes()
}, func(home sensonet.Home) (string, error) {
return home.SystemID, nil
})
if err != nil {
return nil, err
}
systemId := home.SystemID
heating := cc.HeatingSetpoint > 0
wwCancel := func() {}
res := &Vaillant{
log: log,
conn: conn,
systemId: systemId,
}
// skipBoost reports whether the (re-)boost can be skipped because the measured
// temperature is already within the hysteresis band below the loadpoint limit
skipBoost := func() bool {
if res.lp == nil || cc.Hysteresis <= 0 {
return false
}
bat, ok := api.Cap[api.Battery](res)
if !ok {
return false
}
temp, err := bat.Soc()
if err != nil {
if !errors.Is(err, api.ErrNotAvailable) {
log.ERROR.Printf("temp: %v", err)
}
return false
}
limit := res.lp.GetLimitSoc()
if limit <= 0 {
return false
}
if hysteresis := float64(limit) - cc.Hysteresis; temp >= hysteresis {
log.DEBUG.Printf("temp: %.1f >= %.1f hysteresis", temp, hysteresis)
return true
}
return false
}
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 !skipBoost() {
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(cc.Reboost):
if skipBoost() {
continue
}
if err := conn.StartHotWaterBoost(systemId, sensonet.HOTWATERINDEX_DEFAULT); err != nil {
log.ERROR.Println("hot water boost:", err)
}
}
}
}()
return nil
default:
return api.ErrNotAvailable
}
}
res.SgReady, err = NewSgReady(ctx, &cc.embed, set, nil, nil)
if err != nil {
return nil, err
}
if devices, _ := conn.GetMpcData(systemId); len(devices) > 0 {
implement.Has(res, implement.Meter(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
}
if !heating || heatingTempSensor {
implement.Has(res, implement.Battery(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 res, nil
}
var _ loadpoint.Controller = (*Vaillant)(nil)
// LoadpointControl implements loadpoint.Controller
func (wb *Vaillant) LoadpointControl(lp loadpoint.API) {
wb.lp = lp
}
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)
}