Repository: evcc-io/evcc
Pull request #18171 (2025-01-11 11:16:45)

Mqtt: improve publishing structs
Follow-up to https://github.com/evcc-io/evcc/pull/17943

This PR removes struct fields marked as `omitempty` from mqtt if their value is empty. This mirrors the behaviour in the JSON api.


Patch:
diff --git a/server/helper.go b/server/helper.go
index 881b6a99..43cd1206 100644
--- a/server/helper.go
+++ b/server/helper.go
@@ -3,7 +3,10 @@ package server
 import (
 	"fmt"
 	"math"
+	"reflect"
+	"slices"
 	"strconv"
+	"strings"
 )
 
 // pass converts a simple api without return value to api with nil error return value
@@ -22,3 +25,10 @@ func parseFloat(payload string) (float64, error) {
 	}
 	return f, err
 }
+
+// omitEmpty returns true if struct field is omitempty
+func omitEmpty(f reflect.StructField) bool {
+	tag := f.Tag.Get("json")
+	values := strings.Split(tag, ",")
+	return slices.Contains(values, "omitempty")
+}
diff --git a/server/mqtt.go b/server/mqtt.go
index d9f4f692..cddd70fd 100644
--- a/server/mqtt.go
+++ b/server/mqtt.go
@@ -101,8 +101,13 @@ func (m *MQTT) publishComplex(topic string, retained bool, payload interface{})
 		// loop struct
 		for i := 0; i < typ.NumField(); i++ {
 			if f := typ.Field(i); f.IsExported() {
-				n := f.Name
-				m.publishComplex(fmt.Sprintf("%s/%s", topic, strings.ToLower(n[:1])+n[1:]), retained, val.Field(i).Interface())
+				topic := fmt.Sprintf("%s/%s", topic, strings.ToLower(f.Name[:1])+f.Name[1:])
+
+				if val.Field(i).IsZero() && omitEmpty(f) {
+					m.publishSingleValue(topic, retained, nil)
+				} else {
+					m.publishComplex(topic, retained, val.Field(i).Interface())
+				}
 			}
 		}
 


Test patch:
diff --git a/server/mqtt_test.go b/server/mqtt_test.go
index a7dd1347..264e8e47 100644
--- a/server/mqtt_test.go
+++ b/server/mqtt_test.go
@@ -2,12 +2,14 @@ package server
 
 import (
 	"math"
+	"slices"
 	"strconv"
 	"testing"
 	"time"
 
+	"github.com/samber/lo"
 	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/suite"
 )
 
 func TestMqttNaNInf(t *testing.T) {
@@ -16,51 +18,111 @@ func TestMqttNaNInf(t *testing.T) {
 	assert.Equal(t, "+Inf", m.encode(math.Inf(0)), "Inf not encoded as string")
 }
 
+type measurement struct {
+	Power        float64   `json:"power"`
+	Energy       float64   `json:"energy,omitempty"`
+	Currents     []float64 `json:"currents,omitempty"`
+	Controllable *bool     `json:"controllable,omitempty"`
+}
+
 func TestPublishTypes(t *testing.T) {
-	var topics, payloads []string
+	suite.Run(t, new(mqttSuite))
+}
 
-	reset := func() {
-		topics = topics[:0]
-		payloads = payloads[:0]
+type mqttSuite struct {
+	suite.Suite
+	*MQTT
+	topics, payloads []string
+}
+
+func (suite *mqttSuite) publish(topic string, retained bool, payload interface{}) {
+	suite.MQTT.publish(topic, retained, payload)
+}
+
+func (suite *mqttSuite) publisher(topic string, retained bool, payload string) {
+	if i := slices.Index(suite.topics, topic); i >= 0 {
+		suite.topics[i] = topic
+		suite.payloads[i] = payload
+	} else {
+		suite.topics = append(suite.topics, topic)
+		suite.payloads = append(suite.payloads, payload)
 	}
+}
 
-	m := &MQTT{
-		publisher: func(topic string, retained bool, payload string) {
-			topics = append(topics, topic)
-			payloads = append(payloads, payload)
-		},
+func (suite *mqttSuite) SetupSuite() {
+	suite.MQTT = &MQTT{
+		publisher: suite.publisher,
 	}
+}
+
+func (suite *mqttSuite) SetupTest() {
+	suite.topics = suite.topics[:0]
+	suite.payloads = suite.payloads[:0]
+}
 
+func (suite *mqttSuite) TestTime() {
 	now := time.Now()
-	m.publish("test", false, now)
-	require.Len(t, topics, 1)
-	assert.Equal(t, strconv.FormatInt(now.Unix(), 10), payloads[0], "time not encoded as unix timestamp")
-	reset()
+	suite.publish("test", false, now)
+	suite.Require().Len(suite.topics, 1)
+	suite.Equal(strconv.FormatInt(now.Unix(), 10), suite.payloads[0], "time not encoded as unix timestamp")
+}
+
+func (suite *mqttSuite) TestBool() {
+	suite.publish("test", false, false)
+	suite.Require().Len(suite.topics, 1)
+	suite.Equal("false", suite.payloads[0])
+}
 
-	m.publish("test", false, struct {
+func (suite *mqttSuite) TestStruct() {
+	suite.publish("test", false, struct {
 		Foo string
 	}{
 		Foo: "bar",
 	})
-	assert.Equal(t, []string{"test/foo"}, topics, "struct mismatch")
-	assert.Equal(t, []string{"bar"}, payloads, "struct mismatch")
-	reset()
+	suite.Equal([]string{"test/foo"}, suite.topics, "topics")
+	suite.Equal([]string{"bar"}, suite.payloads, "payloads")
+}
 
+func (suite *mqttSuite) TestStructPointer() {
 	i := 1
-	m.publish("test", false, struct {
+	suite.publish("test", false, struct {
 		Foo, Bar *int
 	}{
 		Foo: &i,
 		Bar: nil,
 	})
-	assert.Equal(t, []string{"test/foo", "test/bar"}, topics, "pointer mismatch")
-	assert.Equal(t, []string{"1", ""}, payloads, "pointer mismatch")
-	reset()
+	suite.Equal([]string{"test/foo", "test/bar"}, suite.topics, "topics")
+	suite.Equal([]string{"1", ""}, suite.payloads, "payloads")
+}
 
+func (suite *mqttSuite) TestSlice() {
 	slice := []int{10, 20}
-	m.publish("test", false, slice)
-	require.Len(t, topics, 3)
-	assert.Equal(t, []string{"test", "test/1", "test/2"}, topics, "slice mismatch")
-	assert.Equal(t, []string{"2", "10", "20"}, payloads, "slice mismatch")
-	reset()
+	suite.publish("test", false, slice)
+	suite.Require().Len(suite.topics, 3)
+	suite.Equal([]string{"test", "test/1", "test/2"}, suite.topics, "topics")
+	suite.Equal([]string{"2", "10", "20"}, suite.payloads, "payloads")
+}
+
+func (suite *mqttSuite) TestGrid() {
+	topics := []string{"test/power", "test/energy", "test/currents", "test/controllable"}
+
+	suite.publish("test", false, measurement{})
+	suite.Require().Len(suite.topics, 4)
+	suite.Equal(topics, suite.topics, "topics")
+	suite.Equal([]string{"0", "", "", ""}, suite.payloads, "payloads")
+
+	suite.publish("test", false, measurement{Energy: 1})
+	suite.Require().Len(suite.topics, 4)
+	suite.Equal(topics, suite.topics, "topics")
+	suite.Equal([]string{"0", "1", "", ""}, suite.payloads, "payloads")
+
+	suite.publish("test", false, measurement{Controllable: lo.ToPtr(false)})
+	suite.Require().Len(suite.topics, 4)
+	suite.Equal(topics, suite.topics, "topics")
+	suite.Equal([]string{"0", "", "", "false"}, suite.payloads, "payloads")
+
+	suite.publish("test", false, measurement{Currents: []float64{1, 2, 3}})
+	suite.Require().Len(suite.topics, 7)
+	suite.Equal([]string{"test/power", "test/energy", "test/currents", "test/controllable", "test/currents/1", "test/currents/2", "test/currents/3"}, suite.topics, "topics")
+	suite.Equal([]string{"0", "", "3", "", "1", "2", "3"}, suite.payloads, "payloads")
 }