package server import ( "fmt" "reflect" "strconv" "strings" "time" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/cmd/shutdown" "github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/core/site" "github.com/evcc-io/evcc/core/vehicle" "github.com/evcc-io/evcc/plugin/mqtt" "github.com/evcc-io/evcc/util" "github.com/samber/lo" ) // MQTT is the MQTT server. It uses the MQTT client for publishing. type MQTT struct { log *util.Logger Handler *mqtt.Client root string publisher func(topic string, retained bool, payload string) } // NewMQTT creates MQTT server func NewMQTT(root string, site site.API) (*MQTT, error) { m := &MQTT{ log: util.NewLogger("mqtt"), Handler: mqtt.Instance, root: root, } m.publisher = m.publishString err := m.Handler.Cleanup(m.root, true) if err == nil { err = m.Listen(site) } if err != nil { err = fmt.Errorf("mqtt: %w", err) } shutdown.Register(func() { m.log.DEBUG.Println("shutdown cleanup started") if err := m.Handler.Cleanup(m.root, true); err != nil { m.log.ERROR.Printf("shutdown cleanup failed: %v", err) } }) return m, err } func (m *MQTT) encode(v any) string { // nil should erase the value if v == nil || lo.IsNil(v) { return "" } switch val := v.(type) { case string: return val case float64: // trim trailing zeros return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.3f", val), "0"), ".") case time.Time: if val.IsZero() { return "" } return strconv.FormatInt(val.Unix(), 10) case time.Duration: // must be before stringer to convert to seconds instead of string return strconv.Itoa(int(val.Seconds())) case fmt.Stringer: return val.String() default: return fmt.Sprintf("%v", val) } } func mqttTagAttribute(attr string, f reflect.StructField) bool { return tagAttribute("mqtt", attr, f) } func (m *MQTT) publishComplex(topic string, retained bool, payload any) { if _, ok := payload.(fmt.Stringer); ok || payload == nil { m.publishSingleValue(topic, retained, payload) return } if mm, ok := payload.(api.BytesMarshaler); ok { if b, err := mm.MarshalBytes(); err == nil { m.publishSingleValue(topic, retained, string(b)) } else { m.log.ERROR.Printf("marshal bytes: %v", err) } return } if mm, ok := payload.(api.StructMarshaler); ok { if d, err := mm.MarshalStruct(); err != nil { m.log.ERROR.Printf("marshal struct: %v", err) return } else { payload = d // fallthrough } } switch typ := reflect.TypeOf(payload); typ.Kind() { case reflect.Slice: // publish count val := reflect.ValueOf(payload) m.publishSingleValue(topic, retained, val.Len()) // loop slice for i := range val.Len() { m.publishComplex(fmt.Sprintf("%s/%d", topic, i+1), retained, val.Index(i).Interface()) } case reflect.Map: // loop map for iter := reflect.ValueOf(payload).MapRange(); iter.Next(); { k := iter.Key().String() m.publishComplex(fmt.Sprintf("%s/%s", topic, k), retained, iter.Value().Interface()) } case reflect.Struct: val := reflect.ValueOf(payload) typ := val.Type() // loop struct for i := range typ.NumField() { if f := typ.Field(i); f.IsExported() { topic := topic if !mqttTagAttribute("squash", f) { topic = fmt.Sprintf("%s/%s", topic, strings.ToLower(f.Name[:1])+f.Name[1:]) } if val.Field(i).IsZero() && jsonOmitEmpty(f) { m.publishSingleValue(topic, retained, nil) } else { m.publishComplex(topic, retained, val.Field(i).Interface()) } } } case reflect.Pointer: if val := reflect.ValueOf(payload); !val.IsNil() { m.publishComplex(topic, retained, reflect.Indirect(val).Interface()) return } payload = nil fallthrough default: m.publishSingleValue(topic, retained, payload) } } func (m *MQTT) publishString(topic string, retained bool, payload string) { m.Handler.Publish(topic, retained, m.encode(payload)) } func (m *MQTT) publishSingleValue(topic string, retained bool, payload any) { m.publisher(topic, retained, m.encode(payload)) } func (m *MQTT) publish(topic string, retained bool, payload any) { // publish phase values if slice, ok := payload.([]float64); ok && len(slice) == 3 { var total float64 for i, v := range slice { total += v m.publishSingleValue(fmt.Sprintf("%s/l%d", topic, i+1), retained, v) } // publish sum value m.publishSingleValue(topic, retained, total) return } m.publishComplex(topic, retained, payload) } func (m *MQTT) Listen(site site.API) error { if err := m.listenSiteSetters(m.root+"/site", site); err != nil { return err } // loadpoint setters for id, lp := range site.Loadpoints() { topic := fmt.Sprintf("%s/loadpoints/%d", m.root, id+1) if err := m.listenLoadpointSetters(topic, site, lp); err != nil { return err } } // vehicle setters for _, vehicle := range site.Vehicles().Settings() { topic := fmt.Sprintf("%s/vehicles/%s", m.root, vehicle.Name()) if err := m.listenVehicleSetters(topic, vehicle); err != nil { return err } } return nil } func (m *MQTT) listenSiteSetters(topic string, site site.API) error { for _, s := range []setter{ {"bufferSoc", floatSetter(site.SetBufferSoc)}, {"bufferStartSoc", floatSetter(site.SetBufferStartSoc)}, {"batteryDischargeControl", boolSetter(site.SetBatteryDischargeControl)}, {"prioritySoc", floatSetter(site.SetPrioritySoc)}, {"residualPower", floatSetter(site.SetResidualPower)}, {"smartCostLimit", floatPtrSetter(pass(func(limit *float64) { for _, lp := range site.Loadpoints() { lp.SetSmartCostLimit(limit) } }))}, {"smartFeedInPriorityLimit", floatPtrSetter(pass(func(limit *float64) { for _, lp := range site.Loadpoints() { lp.SetSmartFeedInPriorityLimit(limit) } }))}, {"batteryGridChargeLimit", floatPtrSetter(site.SetBatteryGridChargeLimit)}, {"batteryMode", ptrSetter(api.BatteryModeString, func(m *api.BatteryMode) error { if m == nil { m = new(api.BatteryUnknown) } return site.SetBatteryModeExternal(*m) })}, } { if err := m.Handler.ListenSetter(topic+"/"+s.topic, s.fun); err != nil { return err } } return nil } func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint.API) error { for _, s := range []setter{ {"mode", setterFunc(api.ChargeModeString, pass(lp.SetMode))}, {"phasesConfigured", intSetter(lp.SetPhasesConfigured)}, {"limitSoc", intSetter(pass(lp.SetLimitSoc))}, {"priority", intSetter(pass(lp.SetPriority))}, {"minCurrent", floatSetter(lp.SetMinCurrent)}, {"maxCurrent", floatSetter(lp.SetMaxCurrent)}, {"limitEnergy", floatSetter(pass(lp.SetLimitEnergy))}, {"enableThreshold", floatSetter(pass(lp.SetEnableThreshold))}, {"disableThreshold", floatSetter(pass(lp.SetDisableThreshold))}, {"enableDelay", durationSetter(pass(lp.SetEnableDelay))}, {"disableDelay", durationSetter(pass(lp.SetDisableDelay))}, {"smartCostLimit", floatPtrSetter(pass(lp.SetSmartCostLimit))}, {"smartFeedInPriorityLimit", floatPtrSetter(pass(lp.SetSmartFeedInPriorityLimit))}, {"batteryBoost", boolSetter(lp.SetBatteryBoost)}, {"batteryBoostLimit", intSetter(pass(lp.SetBatteryBoostLimit))}, {"planStrategy", planStrategySetter(lp.SetPlanStrategy)}, {"planEnergy", planGoalSetter(lp.SetPlanEnergy)}, {"vehicle", func(payload string) error { // https://github.com/evcc-io/evcc/issues/11184 empty payload is swallowed by listener if isEmpty(payload) { lp.SetVehicle(nil) return nil } vehicle, err := site.Vehicles().ByName(payload) if err == nil { lp.SetVehicle(vehicle.Instance()) } return err }}, } { if err := m.Handler.ListenSetter(topic+"/"+s.topic, s.fun); err != nil { return err } } return nil } func (m *MQTT) listenVehicleSetters(topic string, v vehicle.API) error { for _, s := range []setter{ {"limitSoc", intSetter(pass(v.SetLimitSoc))}, {"minSoc", intSetter(pass(v.SetMinSoc))}, {"planStrategy", planStrategySetter(v.SetPlanStrategy)}, {"planSoc", planGoalSetter(v.SetPlanSoc)}, } { if err := m.Handler.ListenSetter(topic+"/"+s.topic, s.fun); err != nil { return err } } return nil } // Run starts the MQTT publisher for the MQTT API func (m *MQTT) Run(site site.API, in <-chan util.Param) { // number of loadpoints topic := fmt.Sprintf("%s/loadpoints", m.root) m.publish(topic, true, len(site.Loadpoints())) // number of vehicles topic = fmt.Sprintf("%s/vehicles", m.root) m.publish(topic, true, len(site.Vehicles().Settings())) for i := range 10 { m.publish(fmt.Sprintf("%s/site/pv/%d", m.root, i), true, nil) m.publish(fmt.Sprintf("%s/site/battery/%d", m.root, i), true, nil) m.publish(fmt.Sprintf("%s/site/vehicles/%d", m.root, i), true, nil) } // alive indicator var updated time.Time // publish for p := range in { switch { case p.Loadpoint != nil: id := *p.Loadpoint + 1 topic = fmt.Sprintf("%s/loadpoints/%d/%s", m.root, id, p.Key) case p.Key == "vehicles": topic = fmt.Sprintf("%s/vehicles", m.root) default: topic = fmt.Sprintf("%s/site/%s", m.root, p.Key) } // alive indicator if time.Since(updated) > time.Second { updated = time.Now() m.publish(fmt.Sprintf("%s/updated", m.root), true, updated.Unix()) } // value m.publish(topic, true, p.Val) } }