package server import ( "context" "encoding/json" "errors" "io" "maps" "reflect" "slices" "strings" "sync" "time" "dario.cat/mergo" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/templates" "github.com/go-viper/mapstructure/v2" "github.com/samber/lo" "go.yaml.in/yaml/v4" ) const ( typeTemplate = "template" // typeTemplate is the updatable configuration type masked = "***" // masked indicates a masked config parameter value ) var ( customTypes = []string{"custom", "template", "heatpump", "switchsocket", "sgready", "sgready-relay"} ) type configReq struct { config.Properties `json:",inline" mapstructure:",squash"` Yaml string Other map[string]any `json:",inline" mapstructure:",remain"` } // TODO get rid of this 2-pass unmarshal once https://github.com/golang/go/issues/71497 is implemented func (c *configReq) UnmarshalJSON(data []byte) error { var res map[string]any if err := json.Unmarshal(data, &res); err != nil { return err } var cr configReq if err := util.DecodeOther(res, &cr); err != nil { return err } *c = cr return nil } func (c *configReq) Serialise() map[string]any { if c.Yaml != "" { return map[string]any{ "yaml": c.Yaml, } } return c.Other } func propsToMap(props config.Properties) (map[string]any, error) { res := make(map[string]any) if err := mapstructure.Decode(props, &res); err != nil { return nil, err } return lo.PickBy(res, func(k string, v any) bool { if k == "Type" { return false } switch val := v.(type) { case string: return val != "" case bool: return val } return true }), nil } type newFromConfFunc[T any] func(context.Context, string, map[string]any) (T, error) var ( dirty bool mu sync.Mutex ) // ConfigDirty returns the dirty flag func ConfigDirty() bool { mu.Lock() defer mu.Unlock() return dirty } // setConfigDirty sets the dirty flag indicating that a restart is required func setConfigDirty() { mu.Lock() defer mu.Unlock() dirty = true } func templateForConfig(class templates.Class, conf map[string]any) (templates.Template, error) { typ, ok := conf[typeTemplate].(string) if !ok { return templates.Template{}, errors.New("config template not found") } return templates.ByName(class, typ) } // filterValidTemplateParams removes all configuration properties that are not part of the template definition func filterValidTemplateParams(tmpl *templates.Template, conf map[string]any) map[string]any { res := make(map[string]any) // check if template has modbus capability hasModbus := len(tmpl.ModbusChoices()) > 0 for k, v := range conf { if k == "template" { res[k] = v continue } // preserve modbus fields if template supports modbus if hasModbus && slices.Contains(templates.ModbusParams, k) { res[k] = v continue } if i, _ := tmpl.ParamByName(k); i >= 0 { res[k] = v } } return res } // mapTemplateConfig applies a mapping function to device configuration based on template parameters func mapTemplateConfig(class templates.Class, conf map[string]any, fun func(p templates.Param, k string, v any) any) (map[string]any, error) { tmpl, err := templateForConfig(class, conf) if err != nil { return nil, err } return filterValidTemplateParams(&tmpl, lo.MapValues(conf, func(val any, key string) any { if i, p := tmpl.ParamByName(key); i >= 0 { val = fun(p, key, val) } return val })), nil } // sanitizeMasked replaces masked and private configuration properties with the `***` placeholder func sanitizeMasked(class templates.Class, conf map[string]any, hidePrivate bool) (map[string]any, error) { return mapTemplateConfig(class, conf, func(p templates.Param, _ string, v any) any { if p.IsMasked() || hidePrivate && p.IsPrivate() { return masked } return v }) } // mergeMasked replaces masked `***` configuration properties with their actual values func mergeMasked(class templates.Class, conf, old map[string]any) (map[string]any, error) { return mapTemplateConfig(class, conf, func(p templates.Param, k string, v any) any { if p.IsMasked() && v == masked { return old[k] } return v }) } // deviceOther looks up a stored device's `Other` config by class and id. func deviceOther(class templates.Class, id int) (map[string]any, error) { name := config.NameForID(id) switch class { case templates.Charger: return deviceOtherFromHandler(name, config.Chargers()) case templates.Meter: return deviceOtherFromHandler(name, config.Meters()) case templates.Vehicle: return deviceOtherFromHandler(name, config.Vehicles()) case templates.Tariff: return deviceOtherFromHandler(name, config.Tariffs()) case templates.Messenger: return deviceOtherFromHandler(name, config.Messengers()) } return nil, errors.New("unsupported class: " + class.String()) } func deviceOtherFromHandler[T any](name string, h config.Handler[T]) (map[string]any, error) { dev, err := h.ByName(name) if err != nil { return nil, err } return dev.Config().Other, nil } func startDeviceTimeout() (context.Context, context.CancelFunc, chan struct{}) { done := make(chan struct{}) ctx, cancel := context.WithCancel(context.Background()) go func() { select { case <-time.After(10 * time.Second): // timeout - cancel context cancel() case <-done: // success } }() return ctx, cancel, done } func deviceInstanceFromMergedConfig[T any](ctx context.Context, id int, class templates.Class, req configReq, newFromConf newFromConfFunc[T], h config.Handler[T]) (config.Device[T], T, map[string]any, error) { var zero T dev, err := h.ByName(config.NameForID(id)) if err != nil { return nil, zero, nil, err } conf := dev.Config() // TODO merge custom config if req.Yaml != "" { typ, other, err := config.CustomDevice(conf.Type, req.Other) if err != nil { return nil, zero, nil, err } instance, err := newFromConf(ctx, typ, other) return dev, instance, req.Serialise(), err } merged, err := mergeMasked(class, req.Other, conf.Other) if err != nil { return nil, zero, nil, err } instance, err := newFromConf(ctx, conf.Type, merged) return dev, instance, merged, err } type testResult = struct { Value any `json:"value"` Error string `json:"error"` Asleep bool `json:"asleep,omitempty"` } func hasFeature(instance any, f api.Feature) bool { fd, ok := api.Cap[api.FeatureDescriber](instance) return ok && slices.Contains(fd.Features(), f) } // testInstance tests the given instance similar to dump // TODO refactor together with dump func testInstance(ctx context.Context, instance any) map[string]testResult { var resMu sync.Mutex res := make(map[string]testResult) makeResult := func(key string, val any, err error) { tr := testResult{Value: val} if err != nil { if errors.Is(err, api.ErrNotAvailable) { return } // asleep is a valid vehicle state, not an error if errors.Is(err, api.ErrAsleep) { tr.Asleep = true } else { tr.Error = err.Error() } } resMu.Lock() res[key] = tr resMu.Unlock() } var wg sync.WaitGroup // probes run concurrently so a responsive getter still returns when another // blocks; slow getters are abandoned once ctx expires (see below) wg.Go(func() { if dev, ok := api.Cap[api.Meter](instance); ok { val, err := dev.CurrentPower() makeResult("power", val, err) } }) wg.Go(func() { if dev, ok := api.Cap[api.MeterEnergy](instance); ok { val, err := dev.TotalEnergy() makeResult("energy", val, err) } }) wg.Go(func() { if dev, ok := api.Cap[api.MeterReturnEnergy](instance); ok { val, err := dev.ReturnEnergy() makeResult("returnEnergy", val, err) } }) wg.Go(func() { if dev, ok := api.Cap[api.Battery](instance); ok { val, err := dev.Soc() key := "soc" if hasFeature(instance, api.Heating) { key = "temp" } makeResult(key, val, err) } }) wg.Go(func() { if api.HasCap[api.BatteryController](instance) { makeResult("controllable", true, nil) } }) wg.Go(func() { if dev, ok := api.Cap[api.VehicleOdometer](instance); ok { val, err := dev.Odometer() makeResult("odometer", val, err) } }) wg.Go(func() { if dev, ok := api.Cap[api.BatteryCapacity](instance); ok { val := dev.Capacity() makeResult("capacity", val, nil) } }) wg.Go(func() { if dev, ok := api.Cap[api.PhaseCurrents](instance); ok { i1, i2, i3, err := dev.Currents() makeResult("phaseCurrents", []float64{i1, i2, i3}, err) } }) wg.Go(func() { if dev, ok := api.Cap[api.PhaseVoltages](instance); ok { u1, u2, u3, err := dev.Voltages() makeResult("phaseVoltages", []float64{u1, u2, u3}, err) } }) wg.Go(func() { if dev, ok := api.Cap[api.PhasePowers](instance); ok { p1, p2, p3, err := dev.Powers() makeResult("phasePowers", []float64{p1, p2, p3}, err) } }) wg.Go(func() { if dev, ok := api.Cap[api.ChargeState](instance); ok { val, err := dev.Status() makeResult("chargeStatus", val, err) } }) wg.Go(func() { if dev, ok := api.Cap[api.Charger](instance); ok { val, err := dev.Enabled() makeResult("enabled", val, err) } }) wg.Go(func() { if dev, ok := api.Cap[api.ChargeRater](instance); ok { val, err := dev.ChargedEnergy() makeResult("chargedEnergy", val, err) } }) wg.Go(func() { if api.HasCap[api.PhaseSwitcher](instance) { makeResult("phases1p3p", true, nil) } }) wg.Go(func() { if hasFeature(instance, api.Heating) { makeResult("heating", true, nil) } }) wg.Go(func() { if hasFeature(instance, api.IntegratedDevice) { makeResult("integratedDevice", true, nil) } }) wg.Go(func() { if hasFeature(instance, api.SwitchDevice) { makeResult("switchDevice", true, nil) } }) wg.Go(func() { if dev, ok := api.Cap[api.IconDescriber](instance); ok && dev.Icon() != "" { makeResult("icon", dev.Icon(), nil) } }) wg.Go(func() { if cc, ok := api.Cap[api.PhaseDescriber](instance); ok && cc.Phases() == 1 { makeResult("singlePhase", true, nil) } }) wg.Go(func() { if dev, ok := api.Cap[api.VehicleRange](instance); ok { val, err := dev.Range() makeResult("range", val, err) } }) wg.Go(func() { if dev, ok := api.Cap[api.SocLimiter](instance); ok { val, err := dev.GetLimitSoc() key := "vehicleLimitSoc" if hasFeature(instance, api.Heating) { key = "heaterTempLimit" } makeResult(key, val, err) } }) wg.Go(func() { if dev, ok := api.Cap[api.Dimmer](instance); ok { val, err := dev.Dimmed() makeResult("dimmed", val, err) } }) wg.Go(func() { if dev, ok := api.Cap[api.Curtailer](instance); ok { makeResult("curtailable", true, nil) // only reported while actually curtailing if val, err := dev.CurtailedPercent(); err != nil || val < 100 { makeResult("curtailed", val, err) } } }) wg.Go(func() { if dev, ok := api.Cap[api.HEMS](instance); ok { if power := dev.MaxConsumptionPower(); power != nil && *power > 0 { makeResult("dimLimit", *power, nil) } if percent := dev.CurtailedPercent(); percent != nil && *percent < 100 { if limit := dev.MaxProductionPower(); limit != nil { makeResult("curtailLimit", *limit, nil) } } } }) wg.Go(func() { if dev, ok := api.Cap[api.Identifier](instance); ok { val, err := dev.Identify() makeResult("identifier", val, err) } }) wg.Go(func() { if dev, ok := api.Cap[api.Tariff](instance); ok { rates, err := dev.Rates() // Determine field names based on tariff type var valueKey, ratesKey string switch dev.Type() { case api.TariffTypePriceDynamic, api.TariffTypePriceForecast: valueKey = "price" ratesKey = "priceRates" case api.TariffTypeCo2: valueKey = "co2" ratesKey = "co2Rates" case api.TariffTypeSolar: valueKey = "power" ratesKey = "solarRates" case api.TariffTypeTemperature: valueKey = "outdoorTemp" ratesKey = "temperatureRates" default: valueKey = "price" } if err == nil && len(rates) > 0 { // Get current rate value if rate, err := rates.At(time.Now()); err == nil { makeResult(valueKey, rate.Value, nil) } if ratesKey != "" { makeResult(ratesKey, rates, nil) } } } }) done := make(chan struct{}) go func() { wg.Wait() close(done) }() // bound the probe phase: on ctx.Done return collected results so far. A leaked // getter goroutine keeps writing to res safely under resMu while we return a copy. select { case <-done: case <-ctx.Done(): } resMu.Lock() defer resMu.Unlock() return maps.Clone(res) } // mergeMaskedAny similar to mergeMasked but for interfaces func mergeMaskedAny(old, new any) error { return mergo.Merge(new, old, mergo.WithTransformers(&maskedTransformer{})) } type maskedTransformer struct{} func (maskedTransformer) Transformer(typ reflect.Type) func(dst, src reflect.Value) error { // Only provide transformer for booleans to prevent them from being merged if typ.Kind() == reflect.Bool { return func(dst, src reflect.Value) error { // Keep dst value, don't merge return nil } } if typ.Kind() != reflect.String { return nil } return func(dst, src reflect.Value) error { if dst.String() == masked { dst.Set(src) } return nil } } var criticalPluginSources = []string{"script"} func configHasCriticalPlugin(req configReq) bool { if req.Yaml != "" { // any, not map: global yaml configs (circuits) are a list var m any if err := yaml.Unmarshal([]byte(req.Yaml), &m); err != nil { return false // malformed yaml already rejected by decodeDeviceConfig } return valueHasCriticalSource(m) } return valueHasCriticalSource(req.Other) } func valueHasCriticalSource(v any) bool { switch t := v.(type) { case map[string]any: for k, val := range t { if strings.EqualFold(k, "source") { if s, ok := val.(string); ok && slices.Contains(criticalPluginSources, strings.ToLower(strings.TrimSpace(s))) { return true } } if valueHasCriticalSource(val) { return true } } case []any: return slices.ContainsFunc(t, valueHasCriticalSource) } return false } // decodeDeviceConfig extracts device configuration and yaml details func decodeDeviceConfig(r io.Reader) (configReq, error) { var res configReq if err := json.NewDecoder(r).Decode(&res); err != nil { return configReq{}, err } if res.Yaml == "" { return res, nil } if !slices.ContainsFunc(customTypes, func(s string) bool { return strings.EqualFold(res.Type, s) }) { return configReq{}, errors.New("invalid config: yaml only allowed for types " + strings.Join(customTypes, ", ")) } if len(res.Other) != 0 { return configReq{}, errors.New("invalid config: cannot mix yaml and other") } // validate yaml syntax; tolerate whitespace/comment-only input var tmp map[string]any if err := yaml.Unmarshal([]byte(res.Yaml), &tmp); err != nil { return configReq{}, err } res.Other = map[string]any{"yaml": res.Yaml} return res, nil }