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) }