Batteries: consolidate measurements api (BC) (#24887)

This commit is contained in:
andig 2026-02-07 13:14:28 +01:00 • committed by GitHub
parent 15071f4ac9
commit 15fd774062
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 224 additions and 132 deletions

View file

@ -266,7 +266,7 @@ import collector from "@/mixins/collector.js";
import api from "@/api"; import api from "@/api";
import settings from "@/settings"; import settings from "@/settings";
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import type { BatteryMeter, SelectOption, CURRENCY, Forecast } from "@/types/evcc"; import type { Battery, SelectOption, CURRENCY, Forecast } from "@/types/evcc";
import { SMART_COST_TYPE } from "@/types/evcc"; import { SMART_COST_TYPE } from "@/types/evcc";
export default defineComponent({ export default defineComponent({
@ -276,10 +276,9 @@ export default defineComponent({
props: { props: {
bufferSoc: { type: Number, default: 100 }, bufferSoc: { type: Number, default: 100 },
prioritySoc: { type: Number, default: 0 }, prioritySoc: { type: Number, default: 0 },
batterySoc: { type: Number, default: 0 },
bufferStartSoc: { type: Number, default: 0 }, bufferStartSoc: { type: Number, default: 0 },
batteryDischargeControl: Boolean, batteryDischargeControl: Boolean,
battery: { type: Array as PropType<BatteryMeter[]>, default: () => [] }, battery: { type: Object as PropType<Battery> },
batteryGridChargeLimit: { type: Number, default: null }, batteryGridChargeLimit: { type: Number, default: null },
smartCostAvailable: Boolean, smartCostAvailable: Boolean,
smartCostType: String as PropType<SMART_COST_TYPE>, smartCostType: String as PropType<SMART_COST_TYPE>,
@ -300,6 +299,12 @@ export default defineComponent({
usageTabActive() { usageTabActive() {
return !this.gridTabActive; return !this.gridTabActive;
}, },
batterySoc() {
return this.battery?.soc ?? 0;
},
batteryDevices() {
return this.battery?.devices ?? [];
},
priorityOptions() { priorityOptions() {
const options = []; const options = [];
for (let i = 100; i >= 0; i -= 5) { for (let i = 100; i >= 0; i -= 5) {
@ -312,7 +317,7 @@ export default defineComponent({
return options; return options;
}, },
controllable() { controllable() {
return this.battery.some(({ controllable }) => controllable); return this.batteryDevices.some(({ controllable }) => controllable);
}, },
gridChargePossible() { gridChargePossible() {
return this.controllable && this.isModalVisible && this.smartCostAvailable; return this.controllable && this.isModalVisible && this.smartCostAvailable;
@ -368,13 +373,13 @@ export default defineComponent({
return this.prioritySoc; return this.prioritySoc;
}, },
batteryDetails() { batteryDetails() {
if (!Array.isArray(this.battery)) { if (!this.batteryDevices.length) {
return; return;
} }
return this.battery const multipleBatteries = this.batteryDevices.length > 1;
return this.batteryDevices
.filter(({ capacity }) => capacity > 0) .filter(({ capacity }) => capacity > 0)
.map(({ soc = 0, capacity }) => { .map(({ soc = 0, capacity }) => {
const multipleBatteries = this.battery.length > 1;
const energy = this.fmtWh( const energy = this.fmtWh(
(capacity / 100) * soc * 1e3, (capacity / 100) * soc * 1e3,
POWER_UNIT.KW, POWER_UNIT.KW,

View file

@ -129,9 +129,9 @@
<template v-if="batteryGridChargeLimitSet" #subline> <template v-if="batteryGridChargeLimitSet" #subline>
<div class="d-none d-md-block">&nbsp;</div> <div class="d-none d-md-block">&nbsp;</div>
</template> </template>
<template v-if="battery.length > 1" #expanded> <template v-if="hasMultipleBatteries" #expanded>
<EnergyflowEntry <EnergyflowEntry
v-for="(b, index) in battery" v-for="(b, index) in batteryDevices"
:key="index" :key="index"
:name="b.title || genericBatteryTitle(index)" :name="b.title || genericBatteryTitle(index)"
:details="b.soc" :details="b.soc"
@ -271,9 +271,9 @@
</span> </span>
</button> </button>
</template> </template>
<template v-if="battery.length > 1" #expanded> <template v-if="hasMultipleBatteries" #expanded>
<EnergyflowEntry <EnergyflowEntry
v-for="(b, index) in battery" v-for="(b, index) in batteryDevices"
:key="index" :key="index"
:name="b.title || genericBatteryTitle(index)" :name="b.title || genericBatteryTitle(index)"
:details="b.soc" :details="b.soc"
@ -316,7 +316,7 @@ import collector from "@/mixins/collector.js";
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import { import {
SMART_COST_TYPE, SMART_COST_TYPE,
type BatteryMeter, type Battery,
type Meter, type Meter,
type CURRENCY, type CURRENCY,
type Forecast, type Forecast,
@ -342,9 +342,7 @@ export default defineComponent({
pvPower: { type: Number, default: 0 }, pvPower: { type: Number, default: 0 },
loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] }, loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
batteryConfigured: { type: Boolean }, batteryConfigured: { type: Boolean },
battery: { type: Array as PropType<BatteryMeter[]>, default: () => [] }, battery: { type: Object as PropType<Battery> },
batteryPower: { type: Number, default: 0 },
batterySoc: { type: Number, default: 0 },
batteryDischargeControl: { type: Boolean }, batteryDischargeControl: { type: Boolean },
batteryGridChargeLimit: { type: Number }, batteryGridChargeLimit: { type: Number },
batteryGridChargeActive: { type: Boolean }, batteryGridChargeActive: { type: Boolean },
@ -388,6 +386,18 @@ export default defineComponent({
pvProduction() { pvProduction() {
return Math.abs(this.pvPower); return Math.abs(this.pvPower);
}, },
batterySoc() {
return this.battery?.soc;
},
batteryPower() {
return this.battery?.power ?? 0;
},
batteryDevices() {
return this.battery?.devices ?? [];
},
hasMultipleBatteries() {
return this.batteryDevices.length > 1;
},
batteryDischarge() { batteryDischarge() {
return this.dischargePower(this.batteryPower); return this.dischargePower(this.batteryPower);
}, },

View file

@ -128,13 +128,13 @@ export default defineComponent({
selfBattery: { type: Number, default: 0 }, selfBattery: { type: Number, default: 0 },
pvExport: { type: Number, default: 0 }, pvExport: { type: Number, default: 0 },
loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] }, loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
batterySoc: { type: Number },
batteryCharge: { type: Number, default: 0 }, batteryCharge: { type: Number, default: 0 },
batteryDischarge: { type: Number, default: 0 }, batteryDischarge: { type: Number, default: 0 },
batteryHold: { type: Boolean, default: false }, batteryHold: { type: Boolean, default: false },
batteryGridCharge: { type: Boolean, default: false }, batteryGridCharge: { type: Boolean, default: false },
pvProduction: { type: Number, default: 0 }, pvProduction: { type: Number, default: 0 },
homePower: { type: Number, default: 0 }, homePower: { type: Number, default: 0 },
batterySoc: { type: Number, default: 0 },
powerUnit: { type: String as PropType<POWER_UNIT>, default: POWER_UNIT.KW }, powerUnit: { type: String as PropType<POWER_UNIT>, default: POWER_UNIT.KW },
inPower: { type: Number, default: 0 }, inPower: { type: Number, default: 0 },
outPower: { type: Number, default: 0 }, outPower: { type: Number, default: 0 },

View file

@ -88,7 +88,7 @@ import WelcomeIcons from "./WelcomeIcons.vue";
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import type { import type {
AuthProviders, AuthProviders,
BatteryMeter, Battery,
Meter, Meter,
CURRENCY, CURRENCY,
Forecast, Forecast,
@ -128,13 +128,11 @@ export default defineComponent({
pv: { type: Array as PropType<Meter[]>, default: () => [] }, pv: { type: Array as PropType<Meter[]>, default: () => [] },
aux: { type: Array as PropType<Meter[]>, default: () => [] }, aux: { type: Array as PropType<Meter[]>, default: () => [] },
ext: { type: Array as PropType<Meter[]>, default: () => [] }, ext: { type: Array as PropType<Meter[]>, default: () => [] },
batteryPower: Number,
batterySoc: Number,
batteryDischargeControl: Boolean, batteryDischargeControl: Boolean,
batteryGridChargeLimit: { type: Number, default: null }, batteryGridChargeLimit: { type: Number, default: null },
batteryGridChargeActive: Boolean, batteryGridChargeActive: Boolean,
batteryMode: String, batteryMode: String,
battery: { type: Array as PropType<BatteryMeter[]>, default: () => [] }, battery: { type: Object as PropType<Battery> },
gridCurrents: Array, gridCurrents: Array,
prioritySoc: Number, prioritySoc: Number,
bufferSoc: Number, bufferSoc: Number,
@ -174,8 +172,11 @@ export default defineComponent({
orderedVisibleLoadpoints() { orderedVisibleLoadpoints() {
return this.loadpoints.filter((lp) => lp.visible); return this.loadpoints.filter((lp) => lp.visible);
}, },
batterySoc() {
return this.battery?.soc;
},
batteryConfigured() { batteryConfigured() {
return this.battery?.length > 0; return this.battery && this.battery.devices.length > 0;
}, },
pvConfigured() { pvConfigured() {
return this.pv?.length > 0; return this.pv?.length > 0;

View file

@ -154,7 +154,7 @@ import { logout, isLoggedIn, openLoginModal } from "../Auth/auth";
import { isApp, sendToApp } from "@/utils/native"; import { isApp, sendToApp } from "@/utils/native";
import { isUserConfigError } from "@/utils/fatal"; import { isUserConfigError } from "@/utils/fatal";
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import type { FatalError, Sponsor, EvOpt, AuthProviders } from "@/types/evcc"; import type { FatalError, Sponsor, EvOpt, AuthProviders, Battery } from "@/types/evcc";
export default defineComponent({ export default defineComponent({
name: "TopNavigation", name: "TopNavigation",
@ -163,7 +163,7 @@ export default defineComponent({
authProviders: { type: Object as PropType<AuthProviders>, default: () => ({}) }, authProviders: { type: Object as PropType<AuthProviders>, default: () => ({}) },
sponsor: { type: Object as PropType<Sponsor>, default: () => ({}) }, sponsor: { type: Object as PropType<Sponsor>, default: () => ({}) },
forecast: Object, forecast: Object,
battery: Array, battery: { type: Object as PropType<Battery> },
evopt: { type: Object as PropType<EvOpt>, required: false }, evopt: { type: Object as PropType<EvOpt>, required: false },
fatal: { type: Array as PropType<FatalError[]>, default: () => [] }, fatal: { type: Array as PropType<FatalError[]>, default: () => [] },
}, },
@ -176,7 +176,7 @@ export default defineComponent({
}, },
computed: { computed: {
batteryConfigured() { batteryConfigured() {
return this.battery?.length; return this.battery && this.battery.devices.length;
}, },
providers() { providers() {
return Object.entries(this.authProviders) return Object.entries(this.authProviders)

View file

@ -71,7 +71,7 @@ export interface State {
version?: string; version?: string;
system?: string; system?: string;
timezone?: string; timezone?: string;
battery?: BatteryMeter[]; battery?: Battery;
pv?: Meter[]; pv?: Meter[];
aux?: Meter[]; aux?: Meter[];
ext?: Meter[]; ext?: Meter[];
@ -490,6 +490,13 @@ export interface Meter {
energy?: number; energy?: number;
} }
export interface Battery {
power: number;
capacity: number;
soc: number;
devices: BatteryMeter[];
}
export interface BatteryMeter extends Meter { export interface BatteryMeter extends Meter {
soc: number; soc: number;
controllable: boolean; controllable: boolean;

View file

@ -69,7 +69,7 @@ export default defineComponent({
return store.state.version; return store.state.version;
}, },
batteryModalAvailabe() { batteryModalAvailabe() {
return store.state.battery?.length; return store.state.battery?.devices.length;
}, },
showRoutes() { showRoutes() {
return this.state.startupCompleted; return this.state.startupCompleted;

View file

@ -40,7 +40,6 @@ const (
AuxMeters = "auxMeters" AuxMeters = "auxMeters"
// battery settings // battery settings
BatteryCapacity = "batteryCapacity"
BatteryDischargeControl = "batteryDischargeControl" BatteryDischargeControl = "batteryDischargeControl"
BatteryGridChargeLimit = "batteryGridChargeLimit" BatteryGridChargeLimit = "batteryGridChargeLimit"
BatteryGridChargeActive = "batteryGridChargeActive" BatteryGridChargeActive = "batteryGridChargeActive"
@ -48,11 +47,8 @@ const (
BufferStartSoc = "bufferStartSoc" BufferStartSoc = "bufferStartSoc"
// battery status // battery status
Battery = "battery" Battery = "battery"
BatteryEnergy = "batteryEnergy" BatteryMode = "batteryMode"
BatteryMode = "batteryMode"
BatteryPower = "batteryPower"
BatterySoc = "batterySoc"
// external battery control // external battery control
BatteryModeExternal = "batteryModeExternal" BatteryModeExternal = "batteryModeExternal"

View file

@ -25,6 +25,7 @@ import (
"github.com/evcc-io/evcc/core/session" "github.com/evcc-io/evcc/core/session"
"github.com/evcc-io/evcc/core/site" "github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/core/soc" "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/core/vehicle"
"github.com/evcc-io/evcc/push" "github.com/evcc-io/evcc/push"
"github.com/evcc-io/evcc/server/db" "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) 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) var _ site.API = (*Site)(nil)
// Site is the main configuration container. A site can host multiple loadpoints. // Site is the main configuration container. A site can host multiple loadpoints.
@ -112,16 +92,14 @@ type Site struct {
householdSlotStart time.Time householdSlotStart time.Time
// cached state // cached state
gridPower float64 // Grid power gridPower float64 // Grid power
pvPower float64 // PV power pvPower float64 // PV power
excessDCPower float64 // PV excess DC charge power (hybrid only) excessDCPower float64 // PV excess DC charge power (hybrid only)
auxPower float64 // Aux power auxPower float64 // Aux power
batteryPower float64 // Battery power (charge negative, discharge positive) battery types.BatteryState // Battery cached and published state
batterySoc float64 // Battery soc batteryMode api.BatteryMode // Battery mode (runtime only, not persisted)
batteryCapacity float64 // Battery capacity batteryModeExternal api.BatteryMode // Battery mode (external, runtime only, not persisted)
batteryMode api.BatteryMode // Battery mode (runtime only, not persisted) batteryModeExternalTimer time.Time // Battery mode timer for external control
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 // MetersConfig contains the site's meter configuration
@ -501,8 +479,8 @@ func (site *Site) clearPlanLocks() {
} }
} }
func (site *Site) collectMeters(key string, meters []config.Device[api.Meter]) []measurement { func (site *Site) collectMeters(key string, meters []config.Device[api.Meter]) []types.Measurement {
mm := make([]measurement, len(meters)) mm := make([]types.Measurement, len(meters))
fun := func(i int, dev config.Device[api.Meter]) { fun := func(i int, dev config.Device[api.Meter]) {
meter := dev.Instance() meter := dev.Instance()
@ -529,7 +507,7 @@ func (site *Site) collectMeters(key string, meters []config.Device[api.Meter]) [
} }
props := deviceProperties(dev) props := deviceProperties(dev)
mm[i] = measurement{ mm[i] = types.Measurement{
Title: props.Title, Title: props.Title,
Icon: props.Icon, Icon: props.Icon,
Power: power, Power: power,
@ -573,13 +551,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) 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) 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 return m.Energy
}) })
@ -622,7 +600,7 @@ func (site *Site) updatePvMeters() {
} }
// updateBatteryMeters updates battery meters // updateBatteryMeters updates battery meters
func (site *Site) updateBatteryMeters() []measurement { func (site *Site) updateBatteryMeters() []types.Measurement {
if len(site.batteryMeters) == 0 { if len(site.batteryMeters) == 0 {
return nil return nil
} }
@ -652,7 +630,7 @@ func (site *Site) updateBatteryMeters() []measurement {
mm[i].Controllable = lo.ToPtr(controllable) mm[i].Controllable = lo.ToPtr(controllable)
} }
batterySocAcc := lo.SumBy(mm, func(m measurement) float64 { batterySocAcc := lo.SumBy(mm, func(m types.Measurement) float64 {
if m.Soc == nil { if m.Soc == nil {
return 0 return 0
} }
@ -662,7 +640,7 @@ func (site *Site) updateBatteryMeters() []measurement {
} }
return *m.Soc return *m.Soc
}) })
totalCapacity := lo.SumBy(mm, func(m measurement) float64 { totalCapacity := lo.SumBy(mm, func(m types.Measurement) float64 {
if m.Capacity == nil { if m.Capacity == nil {
return 0 return 0
} }
@ -673,27 +651,23 @@ func (site *Site) updateBatteryMeters() []measurement {
if totalCapacity == 0 { if totalCapacity == 0 {
totalCapacity = float64(len(site.batteryMeters)) totalCapacity = float64(len(site.batteryMeters))
} }
site.batterySoc = batterySocAcc / totalCapacity site.battery.Soc = batterySocAcc / totalCapacity
site.batteryCapacity = totalCapacity site.battery.Capacity = totalCapacity
site.batteryPower = lo.SumBy(mm, func(m measurement) float64 { site.battery.Power = lo.SumBy(mm, func(m types.Measurement) float64 {
return m.Power return m.Power
}) })
totalEnergy := lo.SumBy(mm, func(m measurement) float64 { site.battery.Energy = lo.SumBy(mm, func(m types.Measurement) float64 {
return m.Energy return m.Energy
}) })
if len(site.batteryMeters) > 1 { if len(site.batteryMeters) > 1 {
site.log.DEBUG.Printf("battery power: %.0fW", site.batteryPower) site.log.DEBUG.Printf("battery power: %.0fW", site.battery.Power)
site.log.DEBUG.Printf("battery soc: %.0f%%", math.Round(site.batterySoc)) site.log.DEBUG.Printf("battery soc: %.0f%%", math.Round(site.battery.Soc))
} }
site.publish(keys.BatteryCapacity, site.batteryCapacity) site.battery.Devices = mm
site.publish(keys.BatterySoc, site.batterySoc) site.publish(keys.Battery, site.battery)
site.publish(keys.BatteryPower, site.batteryPower)
site.publish(keys.BatteryEnergy, totalEnergy)
site.publish(keys.Battery, mm)
return mm return mm
} }
@ -705,7 +679,7 @@ func (site *Site) updateAuxMeters() {
} }
mm := site.collectMeters("aux", site.auxMeters) 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 return m.Power
}) })
@ -733,7 +707,7 @@ func (site *Site) updateGridMeter() error {
return nil return nil
} }
var mm measurement var mm types.Measurement
if res, err := backoff.RetryWithData(site.gridMeter.CurrentPower, modbus.Backoff()); err == nil { if res, err := backoff.RetryWithData(site.gridMeter.CurrentPower, modbus.Backoff()); err == nil {
mm.Power = res mm.Power = res
@ -782,7 +756,7 @@ func (site *Site) updateGridMeter() error {
func (site *Site) updateMeters() error { func (site *Site) updateMeters() error {
var eg errgroup.Group var eg errgroup.Group
var battery []measurement var battery []types.Measurement
eg.Go(func() error { site.updatePvMeters(); return nil }) eg.Go(func() error { site.updatePvMeters(); return nil })
eg.Go(func() error { battery = site.updateBatteryMeters(); return nil }) eg.Go(func() error { battery = site.updateBatteryMeters(); return nil })
@ -841,12 +815,12 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b
// allow using PV as estimate for grid power // allow using PV as estimate for grid power
if site.gridMeter == nil { if site.gridMeter == nil {
site.gridPower = totalChargePower - site.pvPower 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 // ensure safe default for residual power
residualPower := site.GetResidualPower() 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, residualPower = 100 // Wsite.publish(keys.PvPower,
} }
@ -861,7 +835,7 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b
} }
// honour battery priority // honour battery priority
batteryPower := site.batteryPower batteryPower := site.battery.Power
excessDCPower := site.excessDCPower excessDCPower := site.excessDCPower
// handed to loadpoint // handed to loadpoint
@ -872,14 +846,14 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b
defer site.RUnlock() defer site.RUnlock()
// if battery is charging below prioritySoc give it priority // if battery is charging below prioritySoc give it priority
if site.batterySoc < site.prioritySoc && batteryPower < 0 { if site.battery.Soc < site.prioritySoc && batteryPower < 0 {
site.log.DEBUG.Printf("battery has priority at soc %.0f%% (< %.0f%%)", site.batterySoc, site.prioritySoc) site.log.DEBUG.Printf("battery has priority at soc %.0f%% (< %.0f%%)", site.battery.Soc, site.prioritySoc)
batteryPower = 0 batteryPower = 0
excessDCPower = 0 excessDCPower = 0
} else { } else {
// if battery is above bufferSoc allow using it for charging // if battery is above bufferSoc allow using it for charging
batteryBuffered = site.bufferSoc > 0 && site.batterySoc > site.bufferSoc batteryBuffered = site.bufferSoc > 0 && site.battery.Soc > site.bufferSoc
batteryStart = site.bufferStartSoc > 0 && site.batterySoc > site.bufferStartSoc batteryStart = site.bufferStartSoc > 0 && site.battery.Soc > site.bufferStartSoc
} }
} }
@ -969,7 +943,7 @@ func (site *Site) update(lp updater) {
if sitePower, batteryBuffered, batteryStart, err := site.sitePower(totalChargePower, flexiblePower); err == nil { 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 // 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) homePower = max(homePower, 0)
site.publish(keys.HomePower, homePower) site.publish(keys.HomePower, homePower)
@ -979,14 +953,14 @@ func (site *Site) update(lp updater) {
// add battery charging power to homePower to ignore all consumption which does not occur on loadpoints // 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 // 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) greenShareHome := site.greenShare(0, homePower)
greenShareLoadpoints := site.greenShare(nonChargePower, nonChargePower+totalChargePower) greenShareLoadpoints := site.greenShare(nonChargePower, nonChargePower+totalChargePower)
// TODO // TODO
if lp != nil { if lp != nil {
lp.Update( 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), greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints),
) )
} }
@ -1033,9 +1007,9 @@ func (site *Site) prepare() {
site.publish(keys.GridConfigured, site.gridMeter != nil) site.publish(keys.GridConfigured, site.gridMeter != nil)
site.publish(keys.Grid, api.Meter(nil)) site.publish(keys.Grid, api.Meter(nil))
site.publish(keys.Pv, []api.Meter{}) site.publish(keys.Pv, []api.Meter{})
site.publish(keys.Battery, []api.Meter{})
site.publish(keys.Aux, []api.Meter{}) site.publish(keys.Aux, []api.Meter{})
site.publish(keys.Ext, []api.Meter{}) site.publish(keys.Ext, []api.Meter{})
site.publish(keys.Battery, nil)
site.publish(keys.PrioritySoc, site.prioritySoc) site.publish(keys.PrioritySoc, site.prioritySoc)
site.publish(keys.BufferSoc, site.bufferSoc) site.publish(keys.BufferSoc, site.bufferSoc)
site.publish(keys.BufferStartSoc, site.bufferStartSoc) site.publish(keys.BufferStartSoc, site.bufferStartSoc)

View file

@ -15,6 +15,7 @@ import (
"github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/metrics" "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/tariff"
"github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/request" "github.com/evcc-io/evcc/util/request"
@ -54,7 +55,7 @@ type responseDetails struct {
const slotsPerHour = float64(time.Hour / tariff.SlotDuration) const slotsPerHour = float64(time.Hour / tariff.SlotDuration)
func (site *Site) optimizerUpdateAsync(battery []measurement) { func (site *Site) optimizerUpdateAsync(battery []types.Measurement) {
var err error var err error
if time.Since(updated) < 2*time.Minute { if time.Since(updated) < 2*time.Minute {
@ -81,7 +82,7 @@ func (site *Site) optimizerUpdateAsync(battery []measurement) {
err = site.optimizerUpdate(battery) err = site.optimizerUpdate(battery)
} }
func (site *Site) optimizerUpdate(battery []measurement) error { func (site *Site) optimizerUpdate(battery []types.Measurement) error {
uri := os.Getenv("EVOPT_URI") uri := os.Getenv("EVOPT_URI")
if uri == "" { if uri == "" {
return nil return nil

View file

@ -32,7 +32,7 @@ type dailyDetails struct {
// - the current green share, calculated for the part of the consumption between powerFrom and powerTo // - 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 // the consumption below powerFrom will get the available green power first
func (site *Site) greenShare(powerFrom float64, powerTo float64) float64 { 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) greenPowerAvailable := math.Max(0, greenPower-powerFrom)
power := powerTo - powerFrom power := powerTo - powerFrom

View file

@ -6,6 +6,7 @@ import (
"github.com/benbjohnson/clock" "github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/types"
"github.com/evcc-io/evcc/server/db" "github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/config"
@ -105,9 +106,11 @@ func TestGreenShare(t *testing.T) {
t.Log(tc.title) t.Log(tc.title)
s := &Site{ s := &Site{
gridPower: tc.grid, gridPower: tc.grid,
pvPower: tc.pv, pvPower: tc.pv,
batteryPower: tc.battery, battery: types.BatteryState{
Power: tc.battery,
},
} }
totalPower := tc.grid + tc.pv + max(0, tc.battery) totalPower := tc.grid + tc.pv + max(0, tc.battery)

32
core/types/types.go Normal file
View file

@ -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"`
}

View file

@ -35,9 +35,25 @@ func jsonDecoder(r io.Reader) *json.Decoder {
return dec return dec
} }
// omitEmpty returns true if struct field is omitempty // jsonOmitEmpty returns true if struct field is omitempty
func omitEmpty(f reflect.StructField) bool { func jsonOmitEmpty(f reflect.StructField) bool {
tag := f.Tag.Get("json") return tagAttribute("json", "omitempty", f)
values := strings.Split(tag, ",") }
return slices.Contains(values, "omitempty")
// 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
} }

View file

@ -62,6 +62,10 @@ type pointWriter interface {
WritePoint(point *write.Point) WritePoint(point *write.Point)
} }
func influxTagValue(f reflect.StructField) string {
return tagValue("influxdb", f)
}
// writePoint asynchronously writes a point to influx // writePoint asynchronously writes a point to influx
func (m *Influx) writePoint(writer pointWriter, key string, fields map[string]any, tags map[string]string) { 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) m.log.TRACE.Printf("write %s=%v (%v)", key, fields, tags)
@ -79,11 +83,15 @@ func (m *Influx) writeComplexPoint(writer pointWriter, key string, val any, tags
for i := range typ.NumField() { for i := range typ.NumField() {
if f := typ.Field(i); f.IsExported() { if f := typ.Field(i); f.IsExported() {
if val.Field(i).IsZero() && omitEmpty(f) { if val.Field(i).IsZero() && jsonOmitEmpty(f) {
continue continue
} }
key := key + strings.ToUpper(f.Name[:1]) + f.Name[1:] key := key + strings.ToUpper(f.Name[:1]) + f.Name[1:]
if tag := influxTagValue(f); tag != "" {
key = tag
}
val := val.Field(i).Interface() val := val.Field(i).Interface()
m.writeComplexPoint(writer, key, val, tags) m.writeComplexPoint(writer, key, val, tags)

View file

@ -4,6 +4,7 @@ import (
"testing" "testing"
"github.com/benbjohnson/clock" "github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/core/types"
"github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util"
inf2 "github.com/influxdata/influxdb-client-go/v2" inf2 "github.com/influxdata/influxdb-client-go/v2"
"github.com/influxdata/influxdb-client-go/v2/api/write" "github.com/influxdata/influxdb-client-go/v2/api/write"
@ -88,7 +89,7 @@ func (w *influxSuite) TestSlice() {
} }
func (w *influxSuite) TestMeasurement() { 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{ w.Equal([]*write.Point{
inf2.NewPoint("batteryPower", nil, map[string]any{"value": 1.0}, w.clock.Now()), 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()), 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() { 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: 1, Soc: lo.ToPtr(10.0)},
{Power: 2, Soc: lo.ToPtr(20.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()), inf2.NewPoint("gridSoc", map[string]string{"id": "2"}, map[string]any{"value": 20.0}, w.clock.Now()),
}, w.p) }, 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)
}

View file

@ -79,6 +79,10 @@ func (m *MQTT) encode(v any) string {
} }
} }
func mqttTagAttribute(attr string, f reflect.StructField) bool {
return tagAttribute("mqtt", attr, f)
}
func (m *MQTT) publishComplex(topic string, retained bool, payload any) { func (m *MQTT) publishComplex(topic string, retained bool, payload any) {
if _, ok := payload.(fmt.Stringer); ok || payload == nil { if _, ok := payload.(fmt.Stringer); ok || payload == nil {
m.publishSingleValue(topic, retained, payload) m.publishSingleValue(topic, retained, payload)
@ -129,9 +133,14 @@ func (m *MQTT) publishComplex(topic string, retained bool, payload any) {
// loop struct // loop struct
for i := range typ.NumField() { for i := range typ.NumField() {
if f := typ.Field(i); f.IsExported() { 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) m.publishSingleValue(topic, retained, nil)
} else { } else {
m.publishComplex(topic, retained, val.Field(i).Interface()) m.publishComplex(topic, retained, val.Field(i).Interface())

View file

@ -7,6 +7,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/evcc-io/evcc/core/types"
"github.com/samber/lo" "github.com/samber/lo"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite" "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") 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) { func TestMqttTypes(t *testing.T) {
suite.Run(t, new(mqttSuite)) suite.Run(t, new(mqttSuite))
} }
@ -104,22 +97,41 @@ func (suite *mqttSuite) TestSlice() {
suite.Equal([]string{"2", "10", "20"}, suite.payloads, "payloads") suite.Equal([]string{"2", "10", "20"}, suite.payloads, "payloads")
} }
func (suite *mqttSuite) TestGrid() { func (suite *mqttSuite) TestMeasurement() {
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(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(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(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(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")
}
func (suite *mqttSuite) TestBatteryState() {
topics := []string{
"test/power", "test/energy", "test/capacity", "test/soc", "test/devices",
"test/devices/1/title", "test/devices/1/icon", "test/devices/1/power", "test/devices/1/energy", "test/devices/1/powers", "test/devices/1/currents", "test/devices/1/excessDCPower", "test/devices/1/capacity", "test/devices/1/soc", "test/devices/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")
} }