From 2d0bf6820f243864a287f9064db33664bac5df6d Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 31 Oct 2025 12:23:20 +0100 Subject: [PATCH 01/11] wip --- core/keys/site.go | 8 ++------ core/site.go | 40 +++++++++++++++++----------------------- core/site_battery.go | 8 ++++++++ core/site_tariffs.go | 2 +- core/site_test.go | 8 +++++--- 5 files changed, 33 insertions(+), 33 deletions(-) diff --git a/core/keys/site.go b/core/keys/site.go index 45593e4d0c..347693b430 100644 --- a/core/keys/site.go +++ b/core/keys/site.go @@ -40,7 +40,6 @@ const ( AuxMeters = "auxMeters" // battery settings - BatteryCapacity = "batteryCapacity" BatteryDischargeControl = "batteryDischargeControl" BatteryGridChargeLimit = "batteryGridChargeLimit" BatteryGridChargeActive = "batteryGridChargeActive" @@ -48,11 +47,8 @@ const ( BufferStartSoc = "bufferStartSoc" // battery status - Battery = "battery" - BatteryEnergy = "batteryEnergy" - BatteryMode = "batteryMode" - BatteryPower = "batteryPower" - BatterySoc = "batterySoc" + Battery = "battery" + BatteryMode = "batteryMode" // external battery control BatteryModeExternal = "batteryModeExternal" diff --git a/core/site.go b/core/site.go index 933115347e..37104daa02 100644 --- a/core/site.go +++ b/core/site.go @@ -118,9 +118,7 @@ type Site struct { pvPower float64 // PV power excessDCPower float64 // PV excess DC charge power (hybrid only) auxPower float64 // Aux power - batteryPower float64 // Battery power (charge negative, discharge positive) - batterySoc float64 // Battery soc - batteryCapacity float64 // Battery capacity + battery batteryState // Battery cached and published state batteryMode api.BatteryMode // Battery mode (runtime only, not persisted) batteryModeExternal api.BatteryMode // Battery mode (external, runtime only, not persisted) batteryModeExternalTimer time.Time // Battery mode timer for external control @@ -670,26 +668,22 @@ func (site *Site) updateBatteryMeters() []measurement { if totalCapacity == 0 { totalCapacity = float64(len(site.batteryMeters)) } - site.batterySoc = batterySocAcc / totalCapacity - site.batteryCapacity = totalCapacity + site.battery.Soc = batterySocAcc / totalCapacity + site.battery.Capacity = totalCapacity - site.batteryPower = lo.SumBy(mm, func(m measurement) float64 { + site.battery.Power = lo.SumBy(mm, func(m measurement) float64 { return m.Power }) - totalEnergy := lo.SumBy(mm, func(m measurement) float64 { + site.battery.Energy = lo.SumBy(mm, func(m measurement) float64 { return m.Energy }) if len(site.batteryMeters) > 1 { - site.log.DEBUG.Printf("battery power: %.0fW", site.batteryPower) - site.log.DEBUG.Printf("battery soc: %.0f%%", math.Round(site.batterySoc)) + site.log.DEBUG.Printf("battery power: %.0fW", site.battery.Power) + site.log.DEBUG.Printf("battery soc: %.0f%%", math.Round(site.battery.Soc)) } - site.publish(keys.BatteryCapacity, site.batteryCapacity) - site.publish(keys.BatterySoc, site.batterySoc) - - site.publish(keys.BatteryPower, site.batteryPower) - site.publish(keys.BatteryEnergy, totalEnergy) + site.battery.Devices = mm site.publish(keys.Battery, mm) return mm @@ -843,7 +837,7 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b // ensure safe default for residual power residualPower := site.GetResidualPower() - if len(site.batteryMeters) > 0 && site.batterySoc < site.prioritySoc && residualPower <= 0 { + if len(site.batteryMeters) > 0 && site.battery.Soc < site.prioritySoc && residualPower <= 0 { residualPower = 100 // Wsite.publish(keys.PvPower, } @@ -858,7 +852,7 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b } // honour battery priority - batteryPower := site.batteryPower + batteryPower := site.battery.Power excessDCPower := site.excessDCPower // handed to loadpoint @@ -869,14 +863,14 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b defer site.RUnlock() // if battery is charging below prioritySoc give it priority - if site.batterySoc < site.prioritySoc && batteryPower < 0 { - site.log.DEBUG.Printf("battery has priority at soc %.0f%% (< %.0f%%)", site.batterySoc, site.prioritySoc) + if site.battery.Soc < site.prioritySoc && batteryPower < 0 { + site.log.DEBUG.Printf("battery has priority at soc %.0f%% (< %.0f%%)", site.battery.Soc, site.prioritySoc) batteryPower = 0 excessDCPower = 0 } else { // if battery is above bufferSoc allow using it for charging - batteryBuffered = site.bufferSoc > 0 && site.batterySoc > site.bufferSoc - batteryStart = site.bufferStartSoc > 0 && site.batterySoc > site.bufferStartSoc + batteryBuffered = site.bufferSoc > 0 && site.battery.Soc > site.bufferSoc + batteryStart = site.bufferStartSoc > 0 && site.battery.Soc > site.bufferStartSoc } } @@ -950,7 +944,7 @@ func (site *Site) update(lp updater) { if sitePower, batteryBuffered, batteryStart, err := site.sitePower(totalChargePower, flexiblePower); err == nil { // ignore negative pvPower values as that means it is not an energy source but consumption - homePower := site.gridPower + max(0, site.pvPower) + site.batteryPower - totalChargePower + homePower := site.gridPower + max(0, site.pvPower) + site.battery.Power - totalChargePower homePower = max(homePower, 0) site.publish(keys.HomePower, homePower) @@ -960,13 +954,13 @@ func (site *Site) update(lp updater) { // add battery charging power to homePower to ignore all consumption which does not occur on loadpoints // fix for: https://github.com/evcc-io/evcc/issues/11032 - nonChargePower := homePower + max(0, -site.batteryPower) + nonChargePower := homePower + max(0, -site.battery.Power) greenShareHome := site.greenShare(0, homePower) greenShareLoadpoints := site.greenShare(nonChargePower, nonChargePower+totalChargePower) // TODO lp.Update( - sitePower, max(0, site.batteryPower), consumption, feedin, batteryBuffered, batteryStart, + sitePower, max(0, site.battery.Power), consumption, feedin, batteryBuffered, batteryStart, greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints), ) diff --git a/core/site_battery.go b/core/site_battery.go index e48e80a99d..b0aac47025 100644 --- a/core/site_battery.go +++ b/core/site_battery.go @@ -10,6 +10,14 @@ import ( "github.com/evcc-io/evcc/util/config" ) +type batteryState struct { + Power float64 `json:"power"` + Energy float64 `json:"energy,omitempty"` + Capacity float64 `json:"capacity,omitempty"` + Soc float64 `json:"soc"` + Devices []measurement `json:"devices,omitempty"` +} + func batteryModeModified(mode api.BatteryMode) bool { return mode != api.BatteryUnknown && mode != api.BatteryNormal } diff --git a/core/site_tariffs.go b/core/site_tariffs.go index 0bd2d247b7..916b229344 100644 --- a/core/site_tariffs.go +++ b/core/site_tariffs.go @@ -32,7 +32,7 @@ type dailyDetails struct { // - the current green share, calculated for the part of the consumption between powerFrom and powerTo // the consumption below powerFrom will get the available green power first func (site *Site) greenShare(powerFrom float64, powerTo float64) float64 { - greenPower := math.Max(0, site.pvPower) + math.Max(0, site.batteryPower) + greenPower := math.Max(0, site.pvPower) + math.Max(0, site.battery.Power) greenPowerAvailable := math.Max(0, greenPower-powerFrom) power := powerTo - powerFrom diff --git a/core/site_test.go b/core/site_test.go index 1a14e6f953..cdd00da5d3 100644 --- a/core/site_test.go +++ b/core/site_test.go @@ -106,9 +106,11 @@ func TestGreenShare(t *testing.T) { t.Log(tc.title) s := &Site{ - gridPower: tc.grid, - pvPower: tc.pv, - batteryPower: tc.battery, + gridPower: tc.grid, + pvPower: tc.pv, + battery: batteryState{ + Power: tc.battery, + }, } totalPower := tc.grid + tc.pv + max(0, tc.battery) From d0e753143e82122d05d0d37ce03cea813c4b0b1d Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 31 Oct 2025 13:53:46 +0100 Subject: [PATCH 02/11] InfluxDB: allow re-keying using influx struct tag --- core/site_battery.go | 2 +- server/influxdb.go | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/core/site_battery.go b/core/site_battery.go index b0aac47025..39aa3fffa5 100644 --- a/core/site_battery.go +++ b/core/site_battery.go @@ -15,7 +15,7 @@ type batteryState struct { Energy float64 `json:"energy,omitempty"` Capacity float64 `json:"capacity,omitempty"` Soc float64 `json:"soc"` - Devices []measurement `json:"devices,omitempty"` + Devices []measurement `json:"devices,omitempty" influxdb:"battery"` } func batteryModeModified(mode api.BatteryMode) bool { diff --git a/server/influxdb.go b/server/influxdb.go index 1a35be6b03..dcc6dbfb75 100644 --- a/server/influxdb.go +++ b/server/influxdb.go @@ -62,6 +62,13 @@ type pointWriter interface { WritePoint(point *write.Point) } +func tagValue(f reflect.StructField) string { + if tag := f.Tag.Get("influxdb"); tag != "" { + return strings.Split(tag, ",")[0] + } + return "" +} + // writePoint asynchronously writes a point to influx func (m *Influx) writePoint(writer pointWriter, key string, fields map[string]any, tags map[string]string) { m.log.TRACE.Printf("write %s=%v (%v)", key, fields, tags) @@ -83,7 +90,12 @@ func (m *Influx) writeComplexPoint(writer pointWriter, key string, val any, tags continue } - key := key + strings.ToUpper(f.Name[:1]) + f.Name[1:] + key := key + if tag := tagValue(f); tag != "" { + key = tag + } + + key += strings.ToUpper(f.Name[:1]) + f.Name[1:] val := val.Field(i).Interface() m.writeComplexPoint(writer, key, val, tags) From 221d12f78dcc7dfedb4c089e5142215ebefdbff0 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 31 Oct 2025 14:17:32 +0100 Subject: [PATCH 03/11] Fix influx mapping, add tests --- core/site.go | 68 +++++++++++++++-------------------------- core/site_battery.go | 8 ----- core/site_optimizer.go | 5 +-- core/site_test.go | 3 +- core/types/types.go | 32 +++++++++++++++++++ server/influxdb.go | 3 +- server/influxdb_test.go | 22 +++++++++++-- server/mqtt_test.go | 27 ++++++---------- 8 files changed, 92 insertions(+), 76 deletions(-) create mode 100644 core/types/types.go diff --git a/core/site.go b/core/site.go index 37104daa02..e6664a8d4a 100644 --- a/core/site.go +++ b/core/site.go @@ -25,6 +25,7 @@ import ( "github.com/evcc-io/evcc/core/session" "github.com/evcc-io/evcc/core/site" "github.com/evcc-io/evcc/core/soc" + "github.com/evcc-io/evcc/core/types" "github.com/evcc-io/evcc/core/vehicle" "github.com/evcc-io/evcc/push" "github.com/evcc-io/evcc/server/db" @@ -48,27 +49,6 @@ type updater interface { Update(sitePower, batteryBoostPower float64, consumption, feedin api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effectivePrice, effectiveCo2 *float64) } -// measurement is used as slice element for publishing structured data -type measurement struct { - Title string `json:"title,omitempty"` - Icon string `json:"icon,omitempty"` - Power float64 `json:"power"` - Energy float64 `json:"energy,omitempty"` - Powers []float64 `json:"powers,omitempty"` - Currents []float64 `json:"currents,omitempty"` - ExcessDCPower float64 `json:"excessdcpower,omitempty"` - Capacity *float64 `json:"capacity,omitempty"` - Soc *float64 `json:"soc,omitempty"` - Controllable *bool `json:"controllable,omitempty"` -} - -var _ api.TitleDescriber = (*measurement)(nil) - -// GetTitle implements api.TitleDescriber interface for InfluxDB tagging -func (m measurement) GetTitle() string { - return m.Title -} - var _ site.API = (*Site)(nil) // Site is the main configuration container. A site can host multiple loadpoints. @@ -114,14 +94,14 @@ type Site struct { householdSlotStart time.Time // cached state - gridPower float64 // Grid power - pvPower float64 // PV power - excessDCPower float64 // PV excess DC charge power (hybrid only) - auxPower float64 // Aux power - battery batteryState // Battery cached and published state - batteryMode api.BatteryMode // Battery mode (runtime only, not persisted) - batteryModeExternal api.BatteryMode // Battery mode (external, runtime only, not persisted) - batteryModeExternalTimer time.Time // Battery mode timer for external control + gridPower float64 // Grid power + pvPower float64 // PV power + excessDCPower float64 // PV excess DC charge power (hybrid only) + auxPower float64 // Aux power + battery types.BatteryState // Battery cached and published state + batteryMode api.BatteryMode // Battery mode (runtime only, not persisted) + batteryModeExternal api.BatteryMode // Battery mode (external, runtime only, not persisted) + batteryModeExternalTimer time.Time // Battery mode timer for external control } // MetersConfig contains the site's meter configuration @@ -490,8 +470,8 @@ func (site *Site) publish(key string, val interface{}) { site.uiChan <- util.Param{Key: key, Val: val} } -func (site *Site) collectMeters(key string, meters []config.Device[api.Meter]) []measurement { - mm := make([]measurement, len(meters)) +func (site *Site) collectMeters(key string, meters []config.Device[api.Meter]) []types.Measurement { + mm := make([]types.Measurement, len(meters)) fun := func(i int, dev config.Device[api.Meter]) { meter := dev.Instance() @@ -526,7 +506,7 @@ func (site *Site) collectMeters(key string, meters []config.Device[api.Meter]) [ } props := deviceProperties(dev) - mm[i] = measurement{ + mm[i] = types.Measurement{ Title: props.Title, Icon: props.Icon, Power: power, @@ -570,13 +550,13 @@ func (site *Site) updatePvMeters() { } } - site.pvPower = lo.SumBy(mm, func(m measurement) float64 { + site.pvPower = lo.SumBy(mm, func(m types.Measurement) float64 { return max(0, m.Power) }) - site.excessDCPower = lo.SumBy(mm, func(m measurement) float64 { + site.excessDCPower = lo.SumBy(mm, func(m types.Measurement) float64 { return math.Abs(m.ExcessDCPower) }) - totalEnergy := lo.SumBy(mm, func(m measurement) float64 { + totalEnergy := lo.SumBy(mm, func(m types.Measurement) float64 { return m.Energy }) @@ -619,7 +599,7 @@ func (site *Site) updatePvMeters() { } // updateBatteryMeters updates battery meters -func (site *Site) updateBatteryMeters() []measurement { +func (site *Site) updateBatteryMeters() []types.Measurement { if len(site.batteryMeters) == 0 { return nil } @@ -653,14 +633,14 @@ func (site *Site) updateBatteryMeters() []measurement { mm[i].Controllable = lo.ToPtr(controllable) } - batterySocAcc := lo.SumBy(mm, func(m measurement) float64 { + batterySocAcc := lo.SumBy(mm, func(m types.Measurement) float64 { // weigh soc by capacity if *m.Capacity > 0 { return *m.Soc * *m.Capacity } return *m.Soc }) - totalCapacity := lo.SumBy(mm, func(m measurement) float64 { + totalCapacity := lo.SumBy(mm, func(m types.Measurement) float64 { return *m.Capacity }) @@ -671,10 +651,10 @@ func (site *Site) updateBatteryMeters() []measurement { site.battery.Soc = batterySocAcc / totalCapacity site.battery.Capacity = totalCapacity - site.battery.Power = lo.SumBy(mm, func(m measurement) float64 { + site.battery.Power = lo.SumBy(mm, func(m types.Measurement) float64 { return m.Power }) - site.battery.Energy = lo.SumBy(mm, func(m measurement) float64 { + site.battery.Energy = lo.SumBy(mm, func(m types.Measurement) float64 { return m.Energy }) @@ -696,7 +676,7 @@ func (site *Site) updateAuxMeters() { } mm := site.collectMeters("aux", site.auxMeters) - site.auxPower = lo.SumBy(mm, func(m measurement) float64 { + site.auxPower = lo.SumBy(mm, func(m types.Measurement) float64 { return m.Power }) @@ -724,7 +704,7 @@ func (site *Site) updateGridMeter() error { return nil } - var mm measurement + var mm types.Measurement if res, err := backoff.RetryWithData(site.gridMeter.CurrentPower, modbus.Backoff()); err == nil { mm.Power = res @@ -773,7 +753,7 @@ func (site *Site) updateGridMeter() error { func (site *Site) updateMeters() error { var eg errgroup.Group - var battery []measurement + var battery []types.Measurement eg.Go(func() error { site.updatePvMeters(); return nil }) eg.Go(func() error { battery = site.updateBatteryMeters(); return nil }) @@ -832,7 +812,7 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b // allow using PV as estimate for grid power if site.gridMeter == nil { site.gridPower = totalChargePower - site.pvPower - site.publish(keys.Grid, measurement{Power: site.gridPower}) + site.publish(keys.Grid, types.Measurement{Power: site.gridPower}) } // ensure safe default for residual power diff --git a/core/site_battery.go b/core/site_battery.go index 39aa3fffa5..e48e80a99d 100644 --- a/core/site_battery.go +++ b/core/site_battery.go @@ -10,14 +10,6 @@ import ( "github.com/evcc-io/evcc/util/config" ) -type batteryState struct { - Power float64 `json:"power"` - Energy float64 `json:"energy,omitempty"` - Capacity float64 `json:"capacity,omitempty"` - Soc float64 `json:"soc"` - Devices []measurement `json:"devices,omitempty" influxdb:"battery"` -} - func batteryModeModified(mode api.BatteryMode) bool { return mode != api.BatteryUnknown && mode != api.BatteryNormal } diff --git a/core/site_optimizer.go b/core/site_optimizer.go index bd1861aad3..c8ca6ad17b 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -14,6 +14,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/core/metrics" + "github.com/evcc-io/evcc/core/types" "github.com/evcc-io/evcc/tariff" "github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/request" @@ -51,7 +52,7 @@ type responseDetails struct { BatteryDetails []batteryDetail `json:"batteryDetails"` } -func (site *Site) optimizerUpdateAsync(battery []measurement) { +func (site *Site) optimizerUpdateAsync(battery []types.Measurement) { var err error if time.Since(updated) < 2*time.Minute { @@ -78,7 +79,7 @@ func (site *Site) optimizerUpdateAsync(battery []measurement) { err = site.optimizerUpdate(battery) } -func (site *Site) optimizerUpdate(battery []measurement) error { +func (site *Site) optimizerUpdate(battery []types.Measurement) error { uri := os.Getenv("EVOPT_URI") if uri == "" { return nil diff --git a/core/site_test.go b/core/site_test.go index cdd00da5d3..30043b7527 100644 --- a/core/site_test.go +++ b/core/site_test.go @@ -7,6 +7,7 @@ import ( "github.com/benbjohnson/clock" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/metrics" + "github.com/evcc-io/evcc/core/types" "github.com/evcc-io/evcc/server/db" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/config" @@ -108,7 +109,7 @@ func TestGreenShare(t *testing.T) { s := &Site{ gridPower: tc.grid, pvPower: tc.pv, - battery: batteryState{ + battery: types.BatteryState{ Power: tc.battery, }, } diff --git a/core/types/types.go b/core/types/types.go new file mode 100644 index 0000000000..3a167b06cb --- /dev/null +++ b/core/types/types.go @@ -0,0 +1,32 @@ +package types + +import "github.com/evcc-io/evcc/api" + +// Measurement is the device measurements struct +type Measurement struct { + Title string `json:"title,omitempty"` + Icon string `json:"icon,omitempty"` + Power float64 `json:"power"` + Energy float64 `json:"energy,omitempty"` + Powers []float64 `json:"powers,omitempty"` + Currents []float64 `json:"currents,omitempty"` + ExcessDCPower float64 `json:"excessdcpower,omitempty"` + Capacity *float64 `json:"capacity,omitempty"` + Soc *float64 `json:"soc,omitempty"` + Controllable *bool `json:"controllable,omitempty"` +} + +var _ api.TitleDescriber = (*Measurement)(nil) + +// GetTitle implements api.TitleDescriber interface for InfluxDB tagging +func (m Measurement) GetTitle() string { + return m.Title +} + +type BatteryState struct { + Power float64 `json:"power"` + Energy float64 `json:"energy,omitempty"` + Capacity float64 `json:"capacity,omitempty"` + Soc float64 `json:"soc"` + Devices []Measurement `json:"devices,omitempty" influxdb:"battery"` +} diff --git a/server/influxdb.go b/server/influxdb.go index dcc6dbfb75..b5263d305d 100644 --- a/server/influxdb.go +++ b/server/influxdb.go @@ -90,12 +90,11 @@ func (m *Influx) writeComplexPoint(writer pointWriter, key string, val any, tags continue } - key := key + key := key + strings.ToUpper(f.Name[:1]) + f.Name[1:] if tag := tagValue(f); tag != "" { key = tag } - key += strings.ToUpper(f.Name[:1]) + f.Name[1:] val := val.Field(i).Interface() m.writeComplexPoint(writer, key, val, tags) diff --git a/server/influxdb_test.go b/server/influxdb_test.go index fa55323db8..2da388d1ba 100644 --- a/server/influxdb_test.go +++ b/server/influxdb_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/benbjohnson/clock" + "github.com/evcc-io/evcc/core/types" "github.com/evcc-io/evcc/util" inf2 "github.com/influxdata/influxdb-client-go/v2" "github.com/influxdata/influxdb-client-go/v2/api/write" @@ -88,7 +89,7 @@ func (w *influxSuite) TestSlice() { } func (w *influxSuite) TestMeasurement() { - w.WriteParam(util.Param{Key: "battery", Val: measurement{Power: 1, Soc: lo.ToPtr(10.0)}}) + w.WriteParam(util.Param{Key: "battery", Val: types.Measurement{Power: 1, Soc: lo.ToPtr(10.0)}}) w.Equal([]*write.Point{ inf2.NewPoint("batteryPower", nil, map[string]any{"value": 1.0}, w.clock.Now()), inf2.NewPoint("batterySoc", nil, map[string]any{"value": 10.0}, w.clock.Now()), @@ -96,7 +97,7 @@ func (w *influxSuite) TestMeasurement() { } func (w *influxSuite) TestSliceOfStruct() { - w.WriteParam(util.Param{Key: "grid", Val: []measurement{ + w.WriteParam(util.Param{Key: "grid", Val: []types.Measurement{ {Power: 1, Soc: lo.ToPtr(10.0)}, {Power: 2, Soc: lo.ToPtr(20.0)}, }}) @@ -107,3 +108,20 @@ func (w *influxSuite) TestSliceOfStruct() { inf2.NewPoint("gridSoc", map[string]string{"id": "2"}, map[string]any{"value": 20.0}, w.clock.Now()), }, w.p) } + +func (w *influxSuite) TestBatteryState() { + w.WriteParam(util.Param{Key: "battery", Val: types.BatteryState{ + Power: 2, + Soc: 20.0, + Devices: []types.Measurement{{ + Power: 1, + Soc: lo.ToPtr(10.0), + }}, + }}) + w.Equal([]*write.Point{ + inf2.NewPoint("batteryPower", map[string]string{}, map[string]any{"value": 2.0}, w.clock.Now()), + inf2.NewPoint("batterySoc", map[string]string{}, map[string]any{"value": 20.0}, w.clock.Now()), + inf2.NewPoint("batteryPower", map[string]string{"id": "1"}, map[string]any{"value": 1.0}, w.clock.Now()), + inf2.NewPoint("batterySoc", map[string]string{"id": "1"}, map[string]any{"value": 10.0}, w.clock.Now()), + }, w.p) +} diff --git a/server/mqtt_test.go b/server/mqtt_test.go index dca291425c..72be211a36 100644 --- a/server/mqtt_test.go +++ b/server/mqtt_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/evcc-io/evcc/core/types" "github.com/samber/lo" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" @@ -18,14 +19,6 @@ 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"` - Soc *float64 `json:"soc,omitempty"` - Controllable *bool `json:"controllable,omitempty"` -} - func TestMqttTypes(t *testing.T) { suite.Run(t, new(mqttSuite)) } @@ -105,21 +98,21 @@ func (suite *mqttSuite) TestSlice() { } func (suite *mqttSuite) TestGrid() { - topics := []string{"test/power", "test/energy", "test/currents", "test/soc", "test/controllable"} + topics := []string{"test/title", "test/icon", "test/power", "test/energy", "test/powers", "test/currents", "test/excessDCPower", "test/capacity", "test/soc", "test/controllable"} - suite.publish("test", false, measurement{}) + suite.publish("test", false, types.Measurement{}) suite.Equal(topics, suite.topics, "topics") - suite.Equal([]string{"0", "", "", "", ""}, suite.payloads, "payloads") + suite.Equal([]string{"", "", "0", "", "", "", "", "", "", ""}, suite.payloads, "payloads") - suite.publish("test", false, measurement{Energy: 1}) + suite.publish("test", false, types.Measurement{Energy: 1}) suite.Equal(topics, suite.topics, "topics") - suite.Equal([]string{"0", "1", "", "", ""}, suite.payloads, "payloads") + suite.Equal([]string{"", "", "0", "1", "", "", "", "", "", ""}, suite.payloads, "payloads") - suite.publish("test", false, measurement{Controllable: lo.ToPtr(false)}) + suite.publish("test", false, types.Measurement{Controllable: lo.ToPtr(false)}) suite.Equal(topics, suite.topics, "topics") - suite.Equal([]string{"0", "", "", "", "false"}, suite.payloads, "payloads") + suite.Equal([]string{"", "", "0", "", "", "", "", "", "", "false"}, suite.payloads, "payloads") - suite.publish("test", false, measurement{Currents: []float64{1, 2, 3}}) + suite.publish("test", false, types.Measurement{Currents: []float64{1, 2, 3}}) suite.Equal(append(topics, "test/currents/1", "test/currents/2", "test/currents/3"), suite.topics, "topics") - suite.Equal([]string{"0", "", "3", "", "", "1", "2", "3"}, suite.payloads, "payloads") + suite.Equal([]string{"", "", "0", "", "", "3", "", "", "", "", "1", "2", "3"}, suite.payloads, "payloads") } From 3a2a8a697ccf4ae4d92ecb03fdc25d9f0e71fdfc Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 31 Oct 2025 14:49:48 +0100 Subject: [PATCH 04/11] Mqtt: add squash --- core/types/types.go | 2 +- server/helper.go | 26 +++++++++++++++++++++----- server/influxdb.go | 11 ++++------- server/mqtt.go | 13 +++++++++++-- server/mqtt_test.go | 21 ++++++++++++++++++++- 5 files changed, 57 insertions(+), 16 deletions(-) diff --git a/core/types/types.go b/core/types/types.go index 3a167b06cb..36cbaa7bc7 100644 --- a/core/types/types.go +++ b/core/types/types.go @@ -28,5 +28,5 @@ type BatteryState struct { Energy float64 `json:"energy,omitempty"` Capacity float64 `json:"capacity,omitempty"` Soc float64 `json:"soc"` - Devices []Measurement `json:"devices,omitempty" influxdb:"battery"` + Devices []Measurement `json:"devices,omitempty" influxdb:"battery" mqtt:",squash"` } diff --git a/server/helper.go b/server/helper.go index da270c65a0..707b367c83 100644 --- a/server/helper.go +++ b/server/helper.go @@ -51,9 +51,25 @@ func jsonDecoder(r io.Reader) *json.Decoder { return dec } -// 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") +// jsonOmitEmpty returns true if struct field is omitempty +func jsonOmitEmpty(f reflect.StructField) bool { + return tagAttribute("json", "omitempty", f) +} + +// tagValue returns the given tag's primary value +func tagValue(key string, f reflect.StructField) string { + if tag := f.Tag.Get(key); tag != "" { + return strings.Split(tag, ",")[0] + } + return "" +} + +// tagAttribute returns the given tag's primary value +func tagAttribute(key, attr string, f reflect.StructField) bool { + if tag := f.Tag.Get(key); tag != "" { + if attrs := strings.Split(tag, ","); len(attrs) > 1 { + return slices.Contains(attrs[1:], attr) + } + } + return false } diff --git a/server/influxdb.go b/server/influxdb.go index b5263d305d..264394d850 100644 --- a/server/influxdb.go +++ b/server/influxdb.go @@ -62,11 +62,8 @@ type pointWriter interface { WritePoint(point *write.Point) } -func tagValue(f reflect.StructField) string { - if tag := f.Tag.Get("influxdb"); tag != "" { - return strings.Split(tag, ",")[0] - } - return "" +func influxTagValue(f reflect.StructField) string { + return tagValue("influxdb", f) } // writePoint asynchronously writes a point to influx @@ -86,12 +83,12 @@ func (m *Influx) writeComplexPoint(writer pointWriter, key string, val any, tags for i := range typ.NumField() { if f := typ.Field(i); f.IsExported() { - if val.Field(i).IsZero() && omitEmpty(f) { + if val.Field(i).IsZero() && jsonOmitEmpty(f) { continue } key := key + strings.ToUpper(f.Name[:1]) + f.Name[1:] - if tag := tagValue(f); tag != "" { + if tag := influxTagValue(f); tag != "" { key = tag } diff --git a/server/mqtt.go b/server/mqtt.go index c1b60c7b15..aadd811ed9 100644 --- a/server/mqtt.go +++ b/server/mqtt.go @@ -79,6 +79,10 @@ func (m *MQTT) encode(v interface{}) string { } } +func mqttTagAttribute(attr string, f reflect.StructField) bool { + return tagAttribute("mqtt", attr, f) +} + func (m *MQTT) publishComplex(topic string, retained bool, payload interface{}) { if _, ok := payload.(fmt.Stringer); ok || payload == nil { m.publishSingleValue(topic, retained, payload) @@ -129,9 +133,14 @@ func (m *MQTT) publishComplex(topic string, retained bool, payload interface{}) // loop struct for i := range typ.NumField() { if f := typ.Field(i); f.IsExported() { - topic := fmt.Sprintf("%s/%s", topic, strings.ToLower(f.Name[:1])+f.Name[1:]) + topic := topic + if !mqttTagAttribute("squash", f) { + topic = fmt.Sprintf("%s/%s", topic, strings.ToLower(f.Name[:1])+f.Name[1:]) + } else { + println(1) + } - if val.Field(i).IsZero() && omitEmpty(f) { + if val.Field(i).IsZero() && jsonOmitEmpty(f) { m.publishSingleValue(topic, retained, nil) } else { m.publishComplex(topic, retained, val.Field(i).Interface()) diff --git a/server/mqtt_test.go b/server/mqtt_test.go index 72be211a36..fbfe471424 100644 --- a/server/mqtt_test.go +++ b/server/mqtt_test.go @@ -97,7 +97,7 @@ func (suite *mqttSuite) TestSlice() { suite.Equal([]string{"2", "10", "20"}, suite.payloads, "payloads") } -func (suite *mqttSuite) TestGrid() { +func (suite *mqttSuite) TestMeasurement() { topics := []string{"test/title", "test/icon", "test/power", "test/energy", "test/powers", "test/currents", "test/excessDCPower", "test/capacity", "test/soc", "test/controllable"} suite.publish("test", false, types.Measurement{}) @@ -116,3 +116,22 @@ func (suite *mqttSuite) TestGrid() { suite.Equal(append(topics, "test/currents/1", "test/currents/2", "test/currents/3"), suite.topics, "topics") suite.Equal([]string{"", "", "0", "", "", "3", "", "", "", "", "1", "2", "3"}, suite.payloads, "payloads") } + +func (suite *mqttSuite) TestBatteryState() { + topics := []string{ + "test/power", "test/energy", "test/capacity", "test/soc", "test", + "test/1/title", "test/1/icon", "test/1/power", "test/1/energy", "test/1/powers", "test/1/currents", "test/1/excessDCPower", "test/1/capacity", "test/1/soc", "test/1/controllable", + } + + suite.publish("test", false, types.BatteryState{ + Power: 2, + Soc: 20.0, + Devices: []types.Measurement{{ + Power: 1, + Soc: lo.ToPtr(10.0), + }}, + }) + + suite.Equal(topics, suite.topics, "topics") + suite.Equal([]string{"2", "", "", "20", "1", "", "", "1", "", "", "", "", "", "10", ""}, suite.payloads, "payloads") +} From 496c53c89452370874a5c9745778a280071011d1 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 31 Oct 2025 16:30:50 +0100 Subject: [PATCH 05/11] fix publish --- core/site.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/site.go b/core/site.go index e6664a8d4a..7709efc398 100644 --- a/core/site.go +++ b/core/site.go @@ -664,7 +664,7 @@ func (site *Site) updateBatteryMeters() []types.Measurement { } site.battery.Devices = mm - site.publish(keys.Battery, mm) + site.publish(keys.Battery, site.battery) return mm } From 9a62add6053bd65c853e629d7aa02b510e1c437b Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 31 Oct 2025 19:27:23 +0100 Subject: [PATCH 06/11] wip --- core/site.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/site.go b/core/site.go index 6fb74be46f..71abb046e0 100644 --- a/core/site.go +++ b/core/site.go @@ -988,9 +988,9 @@ func (site *Site) prepare() { site.publish(keys.GridConfigured, site.gridMeter != nil) site.publish(keys.Grid, api.Meter(nil)) site.publish(keys.Pv, []api.Meter{}) - site.publish(keys.Battery, []api.Meter{}) site.publish(keys.Aux, []api.Meter{}) site.publish(keys.Ext, []api.Meter{}) + site.publish(keys.Battery, nil) site.publish(keys.PrioritySoc, site.prioritySoc) site.publish(keys.BufferSoc, site.bufferSoc) site.publish(keys.BufferStartSoc, site.bufferStartSoc) From 2e8458dac622c59fe5640a7a27df9dd9a693477e Mon Sep 17 00:00:00 2001 From: Maschga <88616799+Maschga@users.noreply.github.com> Date: Fri, 31 Oct 2025 19:30:03 +0100 Subject: [PATCH 07/11] apply changes to ui (#24905) --- .../Battery/BatterySettingsModal.vue | 13 ++++----- .../js/components/Energyflow/Energyflow.vue | 28 +++++++++---------- .../components/Energyflow/Visualization.vue | 8 +++--- assets/js/components/Site/Site.vue | 14 ++++------ assets/js/components/Top/Navigation.vue | 6 ++-- assets/js/store.ts | 6 ++++ assets/js/types/evcc.ts | 9 +++++- assets/js/views/App.vue | 8 ++++-- 8 files changed, 52 insertions(+), 40 deletions(-) diff --git a/assets/js/components/Battery/BatterySettingsModal.vue b/assets/js/components/Battery/BatterySettingsModal.vue index 9da8b399b5..8152bbb1fc 100644 --- a/assets/js/components/Battery/BatterySettingsModal.vue +++ b/assets/js/components/Battery/BatterySettingsModal.vue @@ -33,7 +33,7 @@

{{ $t("batterySettings.batteryLevel") }}: - {{ fmtSoc(batterySoc) }} + {{ fmtSoc(battery.soc) }} {{ line }} @@ -106,7 +106,7 @@

, default: () => [] }, + battery: { type: Object as PropType, required: true }, batteryGridChargeLimit: { type: Number, default: null }, smartCostAvailable: Boolean, smartCostType: String as PropType, @@ -310,7 +309,7 @@ export default defineComponent({ return options; }, controllable() { - return this.battery.some(({ controllable }) => controllable); + return this.battery.devices.some(({ controllable }) => controllable); }, gridChargePossible() { return this.controllable && this.isModalVisible && this.smartCostAvailable; @@ -372,7 +371,7 @@ export default defineComponent({ return this.battery .filter(({ capacity }) => capacity > 0) .map(({ soc = 0, capacity }) => { - const multipleBatteries = this.battery.length > 1; + const multipleBatteries = this.battery.devices.length > 1; const energy = this.fmtWh( (capacity / 100) * soc * 1e3, POWER_UNIT.KW, diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue index aa90796cca..b0b611236f 100644 --- a/assets/js/components/Energyflow/Energyflow.vue +++ b/assets/js/components/Energyflow/Energyflow.vue @@ -19,7 +19,7 @@ :batteryHold="batteryHold" :pvProduction="pvProduction" :homePower="homePower" - :batterySoc="batterySoc" + :battery="battery" :powerUnit="powerUnit" :vehicleIcons="vehicleIcons" :inPower="inPower" @@ -113,10 +113,10 @@ :powerUnit="powerUnit" :iconProps="{ hold: batteryHold, - soc: batterySoc, + soc: battery.soc, gridCharge: batteryGridChargeActive, }" - :details="batterySoc" + :details="battery.soc" :detailsFmt="batteryFmt" :expanded="batteryExpanded" detailsClickable @@ -127,9 +127,9 @@ -