From 9d14e3d446007590be394e98942bd3c43586d2bd Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Tue, 2 May 2023 08:59:11 +0200 Subject: [PATCH] Add session infos (co2, price, solar %) (#7380) --- assets/js/components/AnimatedNumber.vue | 2 +- assets/js/components/ChargingSessionModal.vue | 69 +++++---- assets/js/components/GridSettingsModal.vue | 3 +- assets/js/components/Loadpoint.vue | 129 ++++++++++++++-- assets/js/components/Loadpoints.vue | 2 + assets/js/components/Site.vue | 1 + assets/js/mixins/formatter.js | 3 + assets/js/sessionInfo.js | 25 +++ assets/js/settings.js | 13 ++ assets/js/views/ChargingSessions.vue | 86 ++++++----- core/db/db.go | 7 +- core/db/session.go | 44 ++++-- core/energy_metrics.go | 110 +++++++++++++ core/energy_metrics_test.go | 146 ++++++++++++++++++ core/loadpoint.go | 25 +-- core/loadpoint_api.go | 11 +- core/loadpoint_session.go | 28 +++- core/loadpoint_session_test.go | 17 +- core/loadpoint_test.go | 115 +++++++------- core/loadpoint_vehicle.go | 2 +- core/loadpoint_vehicle_test.go | 30 ++-- core/site.go | 23 +-- i18n/de.toml | 14 +- i18n/en.toml | 14 +- server/http_session_handler.go | 5 +- 25 files changed, 706 insertions(+), 218 deletions(-) create mode 100644 assets/js/sessionInfo.js create mode 100644 core/energy_metrics.go create mode 100644 core/energy_metrics_test.go diff --git a/assets/js/components/AnimatedNumber.vue b/assets/js/components/AnimatedNumber.vue index 7dc0f3f6b..7df8ebf01 100644 --- a/assets/js/components/AnimatedNumber.vue +++ b/assets/js/components/AnimatedNumber.vue @@ -31,7 +31,7 @@ export default { startVal: this.to, formattingFn: this.format, duration: this.duration, - decimalPlaces: 2, + decimalPlaces: 3, }); if (this.instance.error) { console.error(this.instance.error); diff --git a/assets/js/components/ChargingSessionModal.vue b/assets/js/components/ChargingSessionModal.vue index dd75beba5..bba0813a5 100644 --- a/assets/js/components/ChargingSessionModal.vue +++ b/assets/js/components/ChargingSessionModal.vue @@ -53,11 +53,13 @@ - - {{ $t("session.odometer") }} + + {{ $t("session.date") }} - {{ formatKm(session.odometer) }} + {{ fmtFullDateTime(new Date(session.created), false) }} +
+ {{ fmtFullDateTime(new Date(session.finished), false) }} @@ -68,38 +70,53 @@ {{ fmtKWh(session.chargedEnergy * 1e3) }} - - - {{ $t("session.meterstart") }} + + + {{ $t("sessions.solar") }} - {{ fmtKWh(session.meterStart * 1e3) }} + {{ fmtNumber(session.solarPercentage, 1) }} % ({{ + fmtKWh( + session.chargedEnergy * 10 * session.solarPercentage + ) + }}) - - - {{ $t("session.meterstop") }} + + + {{ $t("session.price") }} + {{ fmtMoney(session.price, currency) }} + {{ fmtCurrencySymbol(currency) }}
+ {{ fmtPricePerKWh(session.pricePerKWh, currency) }} + + + + + {{ $t("session.co2") }} + + + {{ fmtCo2Medium(session.co2PerKWh) }} + + + + + {{ $t("session.odometer") }} + + + {{ formatKm(session.odometer) }} + + + + + {{ $t("session.meter") }} + + + {{ fmtKWh(session.meterStart * 1e3) }}
{{ fmtKWh(session.meterStop * 1e3) }} - - - {{ $t("session.started") }} - - - {{ fmtFullDateTime(new Date(session.created), false) }} - - - - - {{ $t("session.finished") }} - - - {{ fmtFullDateTime(new Date(session.finished), false) }} - - diff --git a/assets/js/components/GridSettingsModal.vue b/assets/js/components/GridSettingsModal.vue index c1a34f22e..d4173bdf8 100644 --- a/assets/js/components/GridSettingsModal.vue +++ b/assets/js/components/GridSettingsModal.vue @@ -211,7 +211,7 @@ export default { return null; }, activeSlotCost() { - const price = this.activeSlot.price; + const price = this.activeSlot?.price; if (price === undefined) { return this.$t("main.targetChargePlan.unknownPrice"); } @@ -232,6 +232,7 @@ export default { }, }, mounted() { + this.selectedSmartCostLimit = this.smartCostLimit; this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible); this.$refs.modal.addEventListener("hidden.bs.modal", this.modalInvisible); }, diff --git a/assets/js/components/Loadpoint.vue b/assets/js/components/Loadpoint.vue index b3f8f8452..50ff4bd67 100644 --- a/assets/js/components/Loadpoint.vue +++ b/assets/js/components/Loadpoint.vue @@ -57,7 +57,7 @@ :label="$t('main.loadpoint.power')" :value="chargePower" :valueFmt="fmtPower" - class="mb-2" + class="mb-2 text-nowrap" align="start" /> - - +
+ +
+ + +
+ +
+ + +
+ + +

0 || this.sessionCo2PerKWh > 0) { + result.push(SESSION.CO2); + } + return result; + }, }, watch: { phaseRemaining() { @@ -321,6 +393,25 @@ export default { const inKw = value == 0 || value >= 1000; return this.fmtKWh(value, inKw); }, + fmtPrice(value) { + return `${this.fmtMoney(value, this.currency)} ${this.fmtCurrencySymbol( + this.currency + )}`; + }, + fmtAvgPrice(value) { + return this.fmtPricePerKWh(value, this.currency, false); + }, + fmtAvgPriceShort(value) { + return this.fmtPricePerKWh(value, this.currency, true); + }, + fmtSolar(value) { + return `${this.fmtNumber(value, 1)}%`; + }, + nextSessionInfo() { + const index = this.sessionOptions.indexOf(this.sessionInfo); + this.sessionInfo = this.sessionOptions[index + 1] || this.sessionOptions[0]; + setSessionInfo(this.id, this.sessionInfo); + }, }, }; @@ -360,4 +451,8 @@ export default { margin: 0 -1.5rem; } } +.sessionInfo { + cursor: pointer; + user-select: none; +} diff --git a/assets/js/components/Loadpoints.vue b/assets/js/components/Loadpoints.vue index f2143901b..960b6ec34 100644 --- a/assets/js/components/Loadpoints.vue +++ b/assets/js/components/Loadpoints.vue @@ -16,6 +16,7 @@ :smartCostUnit="smartCostUnit" :tariffGrid="tariffGrid" :tariffCo2="tariffCo2" + :currency="currency" class="h-100" :class="{ 'loadpoint-unselected': !selected(index) }" @click="scrollTo(index)" @@ -51,6 +52,7 @@ export default { smartCostUnit: String, tariffGrid: Number, tariffCo2: Number, + currency: String, }, data() { return { selectedIndex: 0, snapTimeout: null }; diff --git a/assets/js/components/Site.vue b/assets/js/components/Site.vue index 3db9ad523..038584725 100644 --- a/assets/js/components/Site.vue +++ b/assets/js/components/Site.vue @@ -21,6 +21,7 @@ :smartCostUnit="smartCostUnit" :tariffGrid="tariffGrid" :tariffCo2="tariffCo2" + :currency="currency" />
diff --git a/assets/js/mixins/formatter.js b/assets/js/mixins/formatter.js index f06100b4f..5e8c31cc1 100644 --- a/assets/js/mixins/formatter.js +++ b/assets/js/mixins/formatter.js @@ -194,14 +194,17 @@ export default { fmtPricePerKWh: function (amout = 0, currency = "EUR", short = false) { let unit = currency; let value = amout; + let minimumFractionDigits = "auto"; let maximumFractionDigits = 3; if (["EUR", "USD"].includes(currency)) { value *= 100; unit = "ct"; + minimumFractionDigits = 1; maximumFractionDigits = 1; } return `${new Intl.NumberFormat(this.$i18n.locale, { style: "decimal", + minimumFractionDigits, maximumFractionDigits, }).format(value)} ${unit}${short ? "" : "/kWh"}`; }, diff --git a/assets/js/sessionInfo.js b/assets/js/sessionInfo.js new file mode 100644 index 000000000..8e10fdb70 --- /dev/null +++ b/assets/js/sessionInfo.js @@ -0,0 +1,25 @@ +import settings from "./settings"; + +const TIME = "time"; +const CO2 = "co2"; +const PRICE = "price"; +const AVG_PRICE = "avgPrice"; +const SOLAR = "solar"; +export const SESSION = { + TIME, + AVG_PRICE, + PRICE, + SOLAR, + CO2, +}; + +export function getSessionInfo(index) { + return settings.sessionInfo[index - 1] || TIME; +} + +export function setSessionInfo(index, value) { + const clone = [...settings.sessionInfo]; + clone[index - 1] = value; + clone.map((v) => v || ""); + settings.sessionInfo = clone; +} diff --git a/assets/js/settings.js b/assets/js/settings.js index 1d48a1860..0ce271dd1 100644 --- a/assets/js/settings.js +++ b/assets/js/settings.js @@ -5,6 +5,7 @@ const SETTINGS_THEME = "settings_theme"; const SETTINGS_UNIT = "settings_unit"; const SETTINGS_HIDDEN_FEATURES = "settings_hidden_features"; const SETTINGS_ENERGYFLOW_DETAILS = "settings_energyflow_details"; +const SESSION_INFO = "session_info"; function read(key) { return window.localStorage[key]; @@ -34,6 +35,16 @@ function saveBool(key) { }; } +function readArray(key) { + return (read(key) || "").split(","); +} + +function saveArray(key) { + return (value) => { + save(key)(value.join(",")); + }; +} + const settings = reactive({ telemetry: null, locale: read(SETTINGS_LOCALE), @@ -41,6 +52,7 @@ const settings = reactive({ unit: read(SETTINGS_UNIT), hiddenFeatures: readBool(SETTINGS_HIDDEN_FEATURES), energyflowDetails: readBool(SETTINGS_ENERGYFLOW_DETAILS), + sessionInfo: readArray(SESSION_INFO), }); watch(() => settings.locale, save(SETTINGS_LOCALE)); @@ -48,5 +60,6 @@ watch(() => settings.theme, save(SETTINGS_THEME)); watch(() => settings.unit, save(SETTINGS_UNIT)); watch(() => settings.hiddenFeatures, saveBool(SETTINGS_HIDDEN_FEATURES)); watch(() => settings.energyflowDetails, saveBool(SETTINGS_ENERGYFLOW_DETAILS)); +watch(() => settings.sessionInfo, saveArray(SESSION_INFO)); export default settings; diff --git a/assets/js/views/ChargingSessions.vue b/assets/js/views/ChargingSessions.vue index 7a0095b33..8520cfe0e 100644 --- a/assets/js/views/ChargingSessions.vue +++ b/assets/js/views/ChargingSessions.vue @@ -54,15 +54,25 @@
- +
+ - - + + + @@ -73,42 +83,39 @@ role="button" @click="showDetails(session.id)" > - + - - + + + @@ -166,13 +173,18 @@ export default { }); }, vehicles() { - return store.state.vehicles.map((v, index) => { - return { id: index, title: v }; - }); + return ( + store.state.vehicles?.map((v, index) => { + return { id: index, title: v }; + }) || [] + ); }, selectedSession() { return this.sessions.find((s) => s.id == this.selectedSessionId); }, + currency() { + return store.state.currency; + }, }, mounted() { this.loadSessions(); diff --git a/core/db/db.go b/core/db/db.go index 066068cdf..ead78e683 100644 --- a/core/db/db.go +++ b/core/db/db.go @@ -43,8 +43,11 @@ func New(name string) (*DB, error) { // Session creates a charging session func (s *DB) Session(meter float64) *Session { t := Session{ - Loadpoint: s.name, - MeterStart: meter, + Loadpoint: s.name, + } + + if meter > 0 { + t.MeterStart = &meter } return &t diff --git a/core/db/session.go b/core/db/session.go index 383e3b2d7..b0dd0879a 100644 --- a/core/db/session.go +++ b/core/db/session.go @@ -5,6 +5,7 @@ import ( "encoding/csv" "fmt" "io" + "reflect" "strings" "time" @@ -19,16 +20,20 @@ import ( // Session is a single charging session type Session struct { - ID uint `json:"id" csv:"-" gorm:"primarykey"` - Created time.Time `json:"created"` - Finished time.Time `json:"finished"` - Loadpoint string `json:"loadpoint"` - Identifier string `json:"identifier"` - Vehicle string `json:"vehicle"` - Odometer float64 `json:"odometer" format:"int"` - MeterStart float64 `json:"meterStart" csv:"Meter Start (kWh)" gorm:"column:meter_start_kwh"` - MeterStop float64 `json:"meterStop" csv:"Meter Stop (kWh)" gorm:"column:meter_end_kwh"` - ChargedEnergy float64 `json:"chargedEnergy" csv:"Charged Energy (kWh)" gorm:"column:charged_kwh"` + ID uint `json:"id" csv:"-" gorm:"primarykey"` + Created time.Time `json:"created"` + Finished time.Time `json:"finished"` + Loadpoint string `json:"loadpoint"` + Identifier string `json:"identifier"` + Vehicle string `json:"vehicle"` + Odometer *float64 `json:"odometer" format:"int"` + MeterStart *float64 `json:"meterStart" csv:"Meter Start (kWh)" gorm:"column:meter_start_kwh"` + MeterStop *float64 `json:"meterStop" csv:"Meter Stop (kWh)" gorm:"column:meter_end_kwh"` + ChargedEnergy float64 `json:"chargedEnergy" csv:"Charged Energy (kWh)" gorm:"column:charged_kwh"` + SolarPercentage *float64 `json:"solarPercentage" csv:"Solar (%)" gorm:"column:solar_percentage"` + Price *float64 `json:"price" csv:"Price" gorm:"column:price"` + PricePerKWh *float64 `json:"pricePerKWh" csv:"Price/kWh" gorm:"column:price_per_kwh"` + Co2PerKWh *float64 `json:"co2PerKWh" csv:"CO2/kWh (gCO2eq)" gorm:"column:co2_per_kwh"` } // Sessions is a list of sessions @@ -75,21 +80,26 @@ func (t *Sessions) writeRow(ww *csv.Writer, mp *message.Printer, r Session) erro } var val string - format := f.Tag("format") + digits := 3 + if format := f.Tag("format"); format == "int" { + digits = 0 + } switch v := f.Value().(type) { case float64: - switch format { - case "int": - val = mp.Sprint(number.Decimal(v, number.NoSeparator(), number.MaxFractionDigits(0))) - default: - val = mp.Sprint(number.Decimal(v, number.NoSeparator(), number.MaxFractionDigits(3))) - } + val = mp.Sprint(number.Decimal(v, number.NoSeparator(), number.MaxFractionDigits(digits))) case time.Time: if !v.IsZero() { val = v.Local().Format("2006-01-02 15:04:05") } default: + if rv := reflect.ValueOf(v); rv.Kind() == reflect.Ptr { + if pv := reflect.Indirect(rv); pv.CanFloat() && !rv.IsNil() { + val = mp.Sprint(number.Decimal(pv.Float(), number.NoSeparator(), number.MaxFractionDigits(digits))) + } + break + } + val = fmt.Sprintf("%v", f.Value()) } diff --git a/core/energy_metrics.go b/core/energy_metrics.go new file mode 100644 index 000000000..f2f27e220 --- /dev/null +++ b/core/energy_metrics.go @@ -0,0 +1,110 @@ +package core + +// EnergyMetrics calculates stats about the charged energy and gives you details about price or co2s +type EnergyMetrics struct { + totalKWh float64 // Total amount of energy used (kWh) + solarKWh float64 // Self-produced energy energy (kWh) + price *float64 // Total cost (Currency) + co2 *float64 // Amount of emitted CO2 (gCO2eq) + currentGreenShare float64 // Current share of solar energy of site (0-1) + currentPrice *float64 // Current price per kWh + currentCo2 *float64 // Current co2 emissions +} + +func NewEnergyMetrics() *EnergyMetrics { + em := &EnergyMetrics{} + em.Reset() + + return em +} + +// SetEnvironment updates site information like solar share, price, co2 for use in later calculations +func (em *EnergyMetrics) SetEnvironment(greenShare float64, effPrice, effCo2 *float64) { + em.currentGreenShare = greenShare + em.currentPrice = effPrice + em.currentCo2 = effCo2 +} + +// Update sets the a new value for the total amount of charged energy and updated metrics based on enviroment values +func (em *EnergyMetrics) Update(chargedKWh float64) { + added := chargedKWh - em.totalKWh + // nothing changed or invalid lower value + if added <= 0 { + return + } + em.totalKWh = chargedKWh + em.solarKWh += added * em.currentGreenShare + // optional values + if em.currentPrice != nil { + addedPrice := *em.currentPrice * added + newPrice := addedPrice + if em.price != nil { + newPrice = *em.price + newPrice + } + em.price = &newPrice + } + if em.currentCo2 != nil { + addedCo2 := *em.currentCo2 * added + newCo2 := addedCo2 + if em.co2 != nil { + newCo2 = *em.co2 + newCo2 + } + em.co2 = &newCo2 + } +} + +// Reset sets all calculations to initial values +func (em *EnergyMetrics) Reset() { + em.totalKWh = 0 + em.solarKWh = 0 + em.price = nil + em.co2 = nil +} + +// TotalWh returns the total energy in Wh +func (em *EnergyMetrics) TotalWh() float64 { + return em.totalKWh * 1e3 +} + +// SolarPercentage returns the share of self-produced energy in percent +func (em *EnergyMetrics) SolarPercentage() float64 { + if em.totalKWh == 0 { + return 0 + } + return 100 / em.totalKWh * em.solarKWh +} + +// Price returns the total energy price in Currency +func (em *EnergyMetrics) Price() *float64 { + if em.totalKWh == 0 || em.price == nil { + return nil + } + return em.price +} + +// PricePerKWh returns the average energy price in Currency +func (em *EnergyMetrics) PricePerKWh() *float64 { + if em.totalKWh == 0 || em.price == nil { + return nil + } + price := *em.price / em.totalKWh + return &price +} + +// Co2PerKWh returns the average co2 emissions per kWh +func (em *EnergyMetrics) Co2PerKWh() *float64 { + if em.totalKWh == 0 || em.co2 == nil { + return nil + } + co2 := *em.co2 / em.totalKWh + return &co2 +} + +// Publish publishes metrics with a given prefix +func (em *EnergyMetrics) Publish(prefix string, p publisher) { + p.publish(prefix+"Energy", em.TotalWh()) + p.publish(prefix+"SolarPercentage", em.SolarPercentage()) + p.publish(prefix+"PricePerKWh", em.PricePerKWh()) + p.publish(prefix+"Price", em.Price()) + p.publish(prefix+"Co2PerKWh", em.Co2PerKWh()) +} diff --git a/core/energy_metrics_test.go b/core/energy_metrics_test.go new file mode 100644 index 000000000..1395215ce --- /dev/null +++ b/core/energy_metrics_test.go @@ -0,0 +1,146 @@ +package core + +import ( + "testing" +) + +func isEqualFloat64(a, b *float64) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} + +func TestEnergyMetrics(t *testing.T) { + f := func(f float64) *float64 { return &f } + + type tcStep = struct { + kWh, greenShare float64 + effPrice, effCo2 *float64 + } + + tc := []struct { + title string + steps []tcStep + totalWh, solarPercentage float64 + price, pricePerKWh, co2PerKWh *float64 + }{ + {"initial state", + []tcStep{}, + 0, 0, nil, nil, nil, + }, + {"energy value", + []tcStep{ + {0.1, 0, nil, nil}, + {0.2, 0, nil, nil}, + }, + 200, 0, nil, nil, nil, + }, + {"ignore lower energy value", + []tcStep{ + {0.2, 0, nil, nil}, + {0.1, 0, nil, nil}, + }, + 200, 0, nil, nil, nil, + }, + {"half solar", + []tcStep{ + {0.1, 1, nil, nil}, + {0.2, 0, nil, nil}, + }, + 200, 50, nil, nil, nil, + }, + {"only solar", + []tcStep{ + {0.1, 1, nil, nil}, + {0.2, 1, nil, nil}, + }, + 200, 100, nil, nil, nil, + }, + {"static price", + []tcStep{ + {1, 0, f(0.5), nil}, + }, + 1000, 0, f(0.5), f(0.5), nil, + }, + {"dynamic price", + []tcStep{ + {1, 0, f(1), nil}, + {2, 0, f(0), nil}, + }, + 2000, 0, f(1), f(0.5), nil, + }, + {"dynamic price", + []tcStep{ + {2, 0, f(1), nil}, + {4, 0, f(0), nil}, + }, + 4000, 0, f(2), f(0.5), nil, + }, + {"static co2", + []tcStep{ + {1, 0, nil, f(500)}, + }, + 1000, 0, nil, nil, f(500), + }, + {"dynamic co2", + []tcStep{ + {1, 0, nil, f(1000)}, + {2, 0, nil, f(0)}, + }, + 2000, 0, nil, nil, f(500), + }, + {"grid only, half, full solar, half, grid only", + []tcStep{ + {1, 0, f(2), f(200)}, + {2, 0.5, f(1), f(50)}, + {3, 1, f(0), f(0)}, + {4, 0.5, f(1), f(50)}, + {5, 0, f(2), f(200)}, + }, + 5000, 40, f(6), f(1.2), f(100), + }, + } + + for _, tc := range tc { + //t.Logf("%+v", tc) + + s := NewEnergyMetrics() + + for _, tc := range tc.steps { + s.SetEnvironment(tc.greenShare, tc.effPrice, tc.effCo2) + s.Update(tc.kWh) + } + + if s.TotalWh() != tc.totalWh { + t.Errorf("%s: TotalWh was incorrect, got: %.3f, want: %.3f.", tc.title, s.TotalWh(), tc.totalWh) + } + if s.SolarPercentage() != tc.solarPercentage { + t.Errorf("%s: SolarPercentage was incorrect, got: %.3f, want: %.3f.", tc.title, s.SolarPercentage(), tc.solarPercentage) + } + price := s.Price() + if !isEqualFloat64(price, tc.price) { + t.Errorf("%s: Price was incorrect, got: %v, want: %v.", tc.title, *price, *tc.price) + } + pricePerKWh := s.PricePerKWh() + if !isEqualFloat64(pricePerKWh, tc.pricePerKWh) { + t.Errorf("%s: PricePerKWh was incorrect, got: %v, want: %v.", tc.title, *pricePerKWh, *tc.pricePerKWh) + } + co2PerKWh := s.Co2PerKWh() + if !isEqualFloat64(co2PerKWh, tc.co2PerKWh) { + t.Errorf("%s: Co2PerKWh was incorrect, got: %v, want: %v.", tc.title, *co2PerKWh, *tc.co2PerKWh) + } + } + + // reset + s := NewEnergyMetrics() + s.SetEnvironment(1, f(1), f(1)) + s.Update(1) + s.Reset() + if s.TotalWh() != 0 || s.SolarPercentage() != 0 || s.Co2PerKWh() != nil || s.Price() != nil || s.PricePerKWh() != nil { + t.Errorf("Metrics not properly reset %+v", s) + } +} diff --git a/core/loadpoint.go b/core/loadpoint.go index 13ef79bb4..e2e6dd63f 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -161,12 +161,12 @@ type Loadpoint struct { wakeUpTimer *Timer // Vehicle wake-up timeout // charge progress - vehicleSoc float64 // Vehicle Soc - chargeDuration time.Duration // Charge duration - chargedEnergy float64 // Charged energy while connected in Wh - chargeRemainingDuration time.Duration // Remaining charge duration - chargeRemainingEnergy float64 // Remaining charge energy in Wh - progress *Progress // Step-wise progress indicator + vehicleSoc float64 // Vehicle Soc + chargeDuration time.Duration // Charge duration + sessionEnergy *EnergyMetrics // Stats for charged energy by session + chargeRemainingDuration time.Duration // Remaining charge duration + chargeRemainingEnergy float64 // Remaining charge energy in Wh + progress *Progress // Step-wise progress indicator // session log db db.Database @@ -289,6 +289,7 @@ func NewLoadpoint(log *util.Logger) *Loadpoint { Enable: ThresholdConfig{Delay: time.Minute, Threshold: 0}, // t, W Disable: ThresholdConfig{Delay: 3 * time.Minute, Threshold: 0}, // t, W GuardDuration: 5 * time.Minute, + sessionEnergy: NewEnergyMetrics(), progress: NewProgress(0, 10), // soc progress indicator coordinator: coordinator.NewDummy(), // dummy vehicle coordinator tasks: util.NewQueue[Task](), // task queue @@ -432,7 +433,8 @@ func (lp *Loadpoint) evVehicleConnectHandler() { lp.log.INFO.Printf("car connected") // energy - lp.setChargedEnergy(0) + lp.sessionEnergy.Reset() + lp.sessionEnergy.Publish("session", lp) lp.publish("chargedEnergy", lp.getChargedEnergy()) // duration @@ -470,6 +472,7 @@ func (lp *Loadpoint) evVehicleDisconnectHandler() { lp.resetMeasuredPhases() // energy and duration + lp.sessionEnergy.Publish("session", lp) lp.publish("chargedEnergy", lp.getChargedEnergy()) lp.publish("connectedDuration", lp.clock.Since(lp.connectedTime)) @@ -1279,7 +1282,7 @@ func (lp *Loadpoint) publishChargeProgress() { // workaround for Go-E resetting during disconnect, see // https://github.com/evcc-io/evcc/issues/5092 if f > lp.chargedAtStartup { - lp.setChargedEnergy(1e3 * (f - lp.chargedAtStartup)) // convert to Wh + lp.sessionEnergy.Update(f - lp.chargedAtStartup) } } else { lp.log.ERROR.Printf("charge rater: %v", err) @@ -1291,6 +1294,8 @@ func (lp *Loadpoint) publishChargeProgress() { lp.log.ERROR.Printf("charge timer: %v", err) } + lp.sessionEnergy.Publish("session", lp) + // deprecated: use sessionEnergy instead lp.publish("chargedEnergy", lp.getChargedEnergy()) lp.publish("chargeDuration", lp.chargeDuration) if _, ok := lp.chargeMeter.(api.MeterEnergy); ok { @@ -1422,7 +1427,7 @@ func (lp *Loadpoint) guardGracePeriodElapsed() bool { } // Update is the main control function. It reevaluates meters and charger state -func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered bool) { +func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered bool, greenShare float64, effPrice, effCo2 *float64) { lp.processTasks() mode := lp.GetMode() @@ -1432,6 +1437,8 @@ func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered bool) lp.updateChargeVoltages() lp.updateChargeCurrents() + lp.sessionEnergy.SetEnvironment(greenShare, effPrice, effCo2) + // update ChargeRater here to make sure initial meter update is caught lp.bus.Publish(evChargeCurrent, lp.chargeCurrent) lp.bus.Publish(evChargePower, lp.chargePower) diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index 2a32e5c1d..95b5c870d 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -68,18 +68,11 @@ func (lp *Loadpoint) SetMode(mode api.ChargeMode) { } } -// getChargedEnergy returns loadpoint charge target energy +// getChargedEnergy returns loadpoint charge target energy in Wh func (lp *Loadpoint) getChargedEnergy() float64 { lp.Lock() defer lp.Unlock() - return lp.chargedEnergy -} - -// setChargedEnergy returns loadpoint charge target energy -func (lp *Loadpoint) setChargedEnergy(energy float64) { - lp.Lock() - defer lp.Unlock() - lp.chargedEnergy = energy + return lp.sessionEnergy.TotalWh() } // GetTargetEnergy returns loadpoint charge target energy diff --git a/core/loadpoint_session.go b/core/loadpoint_session.go index 6dbdf82e1..ee423f738 100644 --- a/core/loadpoint_session.go +++ b/core/loadpoint_session.go @@ -45,24 +45,36 @@ func (lp *Loadpoint) createSession() { // stopSession ends a charging session segment and persists the session. func (lp *Loadpoint) stopSession() { + s := lp.session + // test guard - if lp.db == nil || lp.session == nil { + if lp.db == nil || s == nil { return } // abort the session if charging has never started - if lp.session.Created.IsZero() { + if s.Created.IsZero() { return } - lp.session.Finished = lp.clock.Now() - lp.session.MeterStop = lp.chargeMeterTotal() - - if chargedEnergy := lp.getChargedEnergy() / 1e3; chargedEnergy > lp.session.ChargedEnergy { - lp.session.ChargedEnergy = chargedEnergy + s.Finished = lp.clock.Now() + meterStop := lp.chargeMeterTotal() + if meterStop > 0 { + s.MeterStop = &meterStop } - lp.db.Persist(lp.session) + if chargedEnergy := lp.getChargedEnergy() / 1e3; chargedEnergy > s.ChargedEnergy { + lp.sessionEnergy.Update(chargedEnergy) + } + + solarPerc := lp.sessionEnergy.SolarPercentage() + s.SolarPercentage = &solarPerc + s.Price = lp.sessionEnergy.Price() + s.PricePerKWh = lp.sessionEnergy.PricePerKWh() + s.Co2PerKWh = lp.sessionEnergy.Co2PerKWh() + s.ChargedEnergy = lp.sessionEnergy.TotalWh() / 1e3 + + lp.db.Persist(s) } type sessionOption func(*db.Session) diff --git a/core/loadpoint_session_test.go b/core/loadpoint_session_test.go index 4d0ffaeab..920f4d280 100644 --- a/core/loadpoint_session_test.go +++ b/core/loadpoint_session_test.go @@ -38,10 +38,11 @@ func TestSession(t *testing.T) { cm := &EnergyDecorator{Meter: mm, MeterEnergy: me} lp := &Loadpoint{ - log: util.NewLogger("foo"), - clock: clock, - db: db, - chargeMeter: cm, + log: util.NewLogger("foo"), + clock: clock, + db: db, + chargeMeter: cm, + sessionEnergy: NewEnergyMetrics(), } // create session @@ -59,12 +60,12 @@ func TestSession(t *testing.T) { // stop charging clock.Add(time.Hour) - lp.chargedEnergy = 1.23 * 1e3 // Wh - me.EXPECT().TotalEnergy().Return(1.0+lp.chargedEnergy/1e3, nil) // match chargedEnergy + lp.sessionEnergy.Update(1.23) + me.EXPECT().TotalEnergy().Return(1.0+lp.getChargedEnergy()/1e3, nil) // match chargedEnergy lp.stopSession() assert.NotNil(t, lp.session) - assert.Equal(t, lp.chargedEnergy/1e3, lp.session.ChargedEnergy) + assert.Equal(t, lp.getChargedEnergy()/1e3, lp.session.ChargedEnergy) assert.Equal(t, clock.Now(), lp.session.Finished) s, err := db.Sessions() @@ -74,7 +75,7 @@ func TestSession(t *testing.T) { // stop charging - 2nd leg clock.Add(time.Hour) - lp.chargedEnergy *= 2 + lp.sessionEnergy.Update(lp.getChargedEnergy() * 2) me.EXPECT().TotalEnergy().Return(3.0, nil) // doesn't match chargedEnergy lp.stopSession() diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index ba8629749..2f252ee5a 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -156,18 +156,19 @@ func TestUpdatePowerZero(t *testing.T) { charger := mock.NewMockCharger(ctrl) lp := &Loadpoint{ - log: util.NewLogger("foo"), - bus: evbus.New(), - clock: clck, - charger: charger, - chargeMeter: &Null{}, // silence nil panics - chargeRater: &Null{}, // silence nil panics - chargeTimer: &Null{}, // silence nil panics - wakeUpTimer: NewTimer(), - MinCurrent: minA, - MaxCurrent: maxA, - phases: 1, - status: tc.status, // no status change + log: util.NewLogger("foo"), + bus: evbus.New(), + clock: clck, + charger: charger, + chargeMeter: &Null{}, // silence nil panics + chargeRater: &Null{}, // silence nil panics + chargeTimer: &Null{}, // silence nil panics + wakeUpTimer: NewTimer(), + sessionEnergy: NewEnergyMetrics(), + MinCurrent: minA, + MaxCurrent: maxA, + phases: 1, + status: tc.status, // no status change } attachListeners(t, lp) @@ -181,7 +182,7 @@ func TestUpdatePowerZero(t *testing.T) { } lp.Mode = tc.mode - lp.Update(0, false, false) // sitePower false,0 + lp.Update(0, false, false, 0, nil, nil) // sitePower false,0 ctrl.Finish() } @@ -401,11 +402,12 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) { progress: NewProgress(0, 10), // silence nil panics wakeUpTimer: NewTimer(), // silence nil panics // coordinator: coordinator.NewDummy(), // silence nil panics - MinCurrent: minA, - MaxCurrent: maxA, - vehicle: vehicle, // needed for targetSoc check - socEstimator: socEstimator, // instead of vehicle: vehicle, - Mode: api.ModeNow, + MinCurrent: minA, + MaxCurrent: maxA, + vehicle: vehicle, // needed for targetSoc check + socEstimator: socEstimator, // instead of vehicle: vehicle, + Mode: api.ModeNow, + sessionEnergy: NewEnergyMetrics(), Soc: SocConfig{ target: 90, Poll: PollConfig{ @@ -426,7 +428,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) { charger.EXPECT().Status().Return(api.StatusC, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil) - lp.Update(500, false, false) + lp.Update(500, false, false, 0, nil, nil) t.Log("charging above target - soc deactivates charger") clock.Add(5 * time.Minute) @@ -434,20 +436,20 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) { charger.EXPECT().Status().Return(api.StatusC, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().Enable(false).Return(nil) - lp.Update(500, false, false) + lp.Update(500, false, false, 0, nil, nil) t.Log("deactivated charger changes status to B") clock.Add(5 * time.Minute) vehicle.EXPECT().Soc().Return(95.0, nil) charger.EXPECT().Status().Return(api.StatusB, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) - lp.Update(-5000, false, false) + lp.Update(-5000, false, false, 0, nil, nil) t.Log("soc has fallen below target - soc update prevented by timer") clock.Add(5 * time.Minute) charger.EXPECT().Status().Return(api.StatusB, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) - lp.Update(-5000, false, false) + lp.Update(-5000, false, false, 0, nil, nil) t.Log("soc has fallen below target - soc update timer expired") clock.Add(pollInterval) @@ -455,7 +457,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) { charger.EXPECT().Status().Return(api.StatusB, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().Enable(true).Return(nil) - lp.Update(-5000, false, false) + lp.Update(-5000, false, false, 0, nil, nil) ctrl.Finish() } @@ -466,18 +468,19 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) { charger := mock.NewMockCharger(ctrl) lp := &Loadpoint{ - log: util.NewLogger("foo"), - bus: evbus.New(), - clock: clock, - charger: charger, - chargeMeter: &Null{}, // silence nil panics - chargeRater: &Null{}, // silence nil panics - chargeTimer: &Null{}, // silence nil panics - wakeUpTimer: NewTimer(), - MinCurrent: minA, - MaxCurrent: maxA, - status: api.StatusC, - Mode: api.ModeOff, + log: util.NewLogger("foo"), + bus: evbus.New(), + clock: clock, + charger: charger, + chargeMeter: &Null{}, // silence nil panics + chargeRater: &Null{}, // silence nil panics + chargeTimer: &Null{}, // silence nil panics + wakeUpTimer: NewTimer(), + sessionEnergy: NewEnergyMetrics(), + MinCurrent: minA, + MaxCurrent: maxA, + status: api.StatusC, + Mode: api.ModeOff, Soc: SocConfig{ target: 70, }, @@ -495,14 +498,14 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) { charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().Status().Return(api.StatusC, nil) charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil) - lp.Update(500, false, false) + lp.Update(500, false, false, 0, nil, nil) t.Log("switch off when disconnected") clock.Add(5 * time.Minute) charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().Status().Return(api.StatusA, nil) charger.EXPECT().Enable(false).Return(nil) - lp.Update(-3000, false, false) + lp.Update(-3000, false, false, 0, nil, nil) if lp.Mode != api.ModeOff { t.Error("unexpected mode", lp.Mode) @@ -538,17 +541,18 @@ func TestChargedEnergyAtDisconnect(t *testing.T) { rater := mock.NewMockChargeRater(ctrl) lp := &Loadpoint{ - log: util.NewLogger("foo"), - bus: evbus.New(), - clock: clock, - charger: charger, - chargeMeter: &Null{}, // silence nil panics - chargeRater: rater, - chargeTimer: &Null{}, // silence nil panics - wakeUpTimer: NewTimer(), - MinCurrent: minA, - MaxCurrent: maxA, - status: api.StatusC, + log: util.NewLogger("foo"), + bus: evbus.New(), + clock: clock, + charger: charger, + chargeMeter: &Null{}, // silence nil panics + chargeRater: rater, + chargeTimer: &Null{}, // silence nil panics + wakeUpTimer: NewTimer(), + sessionEnergy: NewEnergyMetrics(), + MinCurrent: minA, + MaxCurrent: maxA, + status: api.StatusC, } attachListeners(t, lp) @@ -564,14 +568,14 @@ func TestChargedEnergyAtDisconnect(t *testing.T) { rater.EXPECT().ChargedEnergy().Return(0.0, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().Status().Return(api.StatusC, nil) - lp.Update(-1, false, false) + lp.Update(-1, false, false, 0, nil, nil) t.Log("at 1:00h charging at 5 kWh") clock.Add(time.Hour) rater.EXPECT().ChargedEnergy().Return(5.0, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().Status().Return(api.StatusC, nil) - lp.Update(-1, false, false) + lp.Update(-1, false, false, 0, nil, nil) expectCache("chargedEnergy", 5000.0) t.Log("at 1:00h stop charging at 5 kWh") @@ -579,7 +583,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) { rater.EXPECT().ChargedEnergy().Return(5.0, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().Status().Return(api.StatusB, nil) - lp.Update(-1, false, false) + lp.Update(-1, false, false, 0, nil, nil) expectCache("chargedEnergy", 5000.0) t.Log("at 1:00h restart charging at 5 kWh") @@ -587,7 +591,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) { rater.EXPECT().ChargedEnergy().Return(5.0, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().Status().Return(api.StatusC, nil) - lp.Update(-1, false, false) + lp.Update(-1, false, false, 0, nil, nil) expectCache("chargedEnergy", 5000.0) t.Log("at 1:30h continue charging at 7.5 kWh") @@ -595,7 +599,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) { rater.EXPECT().ChargedEnergy().Return(7.5, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().Status().Return(api.StatusC, nil) - lp.Update(-1, false, false) + lp.Update(-1, false, false, 0, nil, nil) expectCache("chargedEnergy", 7500.0) t.Log("at 2:00h stop charging at 10 kWh") @@ -603,7 +607,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) { rater.EXPECT().ChargedEnergy().Return(10.0, nil) charger.EXPECT().Enabled().Return(lp.enabled, nil) charger.EXPECT().Status().Return(api.StatusB, nil) - lp.Update(-1, false, false) + lp.Update(-1, false, false, 0, nil, nil) expectCache("chargedEnergy", 10000.0) ctrl.Finish() @@ -768,8 +772,9 @@ func TestMinSoc(t *testing.T) { min: tc.min, }, vehicleSoc: tc.soc, - chargedEnergy: tc.energy, + sessionEnergy: NewEnergyMetrics(), } + lp.sessionEnergy.Update(tc.energy / 1e3) if v := tc.vehicle; v != nil { lp.vehicle = tc.vehicle // avoid assigning nil to interface diff --git a/core/loadpoint_vehicle.go b/core/loadpoint_vehicle.go index 5537b680c..f247d703a 100644 --- a/core/loadpoint_vehicle.go +++ b/core/loadpoint_vehicle.go @@ -306,7 +306,7 @@ func (lp *Loadpoint) vehicleOdometer() { // update session once odometer is read lp.updateSession(func(session *db.Session) { - session.Odometer = odo + session.Odometer = &odo }) } else if !errors.Is(err, api.ErrNotAvailable) { lp.log.ERROR.Printf("vehicle odometer: %v", err) diff --git a/core/loadpoint_vehicle_test.go b/core/loadpoint_vehicle_test.go index 92742c21e..b3209f804 100644 --- a/core/loadpoint_vehicle_test.go +++ b/core/loadpoint_vehicle_test.go @@ -41,6 +41,7 @@ func TestPublishSocAndRange(t *testing.T) { chargeRater: &Null{}, // silence nil panics chargeTimer: &Null{}, // silence nil panics socEstimator: soc.NewEstimator(log, charger, vehicle, false), + sessionEnergy: NewEnergyMetrics(), MinCurrent: minA, MaxCurrent: maxA, phases: 1, @@ -333,18 +334,19 @@ func TestReconnectVehicle(t *testing.T) { charger.EXPECT().Status().Return(api.StatusB, nil).AnyTimes() lp := &Loadpoint{ - log: util.NewLogger("foo"), - bus: evbus.New(), - clock: clck, - charger: charger, - chargeMeter: &Null{}, // silence nil panics - chargeRater: &Null{}, // silence nil panics - chargeTimer: &Null{}, // silence nil panics - wakeUpTimer: NewTimer(), - MinCurrent: minA, - MaxCurrent: maxA, - phases: 1, - Mode: api.ModeNow, + log: util.NewLogger("foo"), + bus: evbus.New(), + clock: clck, + charger: charger, + chargeMeter: &Null{}, // silence nil panics + chargeRater: &Null{}, // silence nil panics + chargeTimer: &Null{}, // silence nil panics + wakeUpTimer: NewTimer(), + sessionEnergy: NewEnergyMetrics(), + MinCurrent: minA, + MaxCurrent: maxA, + phases: 1, + Mode: api.ModeNow, } lp.coordinator = coordinator.NewAdapter(lp, coordinator.New(util.NewLogger("foo"), []api.Vehicle{vehicle})) @@ -359,7 +361,7 @@ func TestReconnectVehicle(t *testing.T) { // vehicle not updated yet vehicle.MockChargeState.EXPECT().Status().Return(api.StatusA, nil) - lp.Update(0, false, false) + lp.Update(0, false, false, 0, nil, nil) ctrl.Finish() // detection started @@ -373,7 +375,7 @@ func TestReconnectVehicle(t *testing.T) { // vehicle not updated yet vehicle.MockChargeState.EXPECT().Status().Return(api.StatusB, nil) - lp.Update(0, false, false) + lp.Update(0, false, false, 0, nil, nil) ctrl.Finish() // vehicle detected diff --git a/core/site.go b/core/site.go index c22d2f733..a84cb145a 100644 --- a/core/site.go +++ b/core/site.go @@ -27,7 +27,7 @@ const standbyPower = 10 // consider less than 10W as charger in standby // Updater abstracts the Loadpoint implementation for testing type Updater interface { loadpoint.API - Update(availablePower float64, autoCharge, batteryBuffered bool) + Update(availablePower float64, autoCharge, batteryBuffered bool, greenShare float64, effectivePrice *float64, effectiveCo2 *float64) } // meterMeasurement is used as slice element for publishing structured data @@ -585,23 +585,25 @@ func (site *Site) greenShare() float64 { } // effectivePrice calculates the real energy price based on self-produced and grid-imported energy. -func (s *Site) effectivePrice(greenShare float64) (float64, error) { +func (s *Site) effectivePrice(greenShare float64) *float64 { if grid, err := s.tariffs.CurrentGridPrice(); err == nil { feedin, err := s.tariffs.CurrentFeedInPrice() if err != nil { feedin = 0 } - return grid*(1-greenShare) + feedin*greenShare, nil + effPrice := grid*(1-greenShare) + feedin*greenShare + return &effPrice } - return 0, api.ErrNotAvailable + return nil } // effectiveCo2 calculates the amount of emitted co2 based on self-produced and grid-imported energy. -func (s *Site) effectiveCo2(greenShare float64) (float64, error) { +func (s *Site) effectiveCo2(greenShare float64) *float64 { if co2, err := s.tariffs.CurrentCo2(); err == nil { - return co2 * (1 - greenShare), nil + effCo2 := co2 * (1 - greenShare) + return &effCo2 } - return 0, api.ErrNotAvailable + return nil } func (s *Site) publishTariffs() { @@ -618,10 +620,10 @@ func (s *Site) publishTariffs() { if co2, err := s.tariffs.CurrentCo2(); err == nil { s.publishDelta("tariffCo2", co2) } - if price, err := s.effectivePrice(greenShare); err == nil { + if price := s.effectivePrice(greenShare); price != nil { s.publish("tariffEffectivePrice", price) } - if co2, err := s.effectiveCo2(greenShare); err == nil { + if co2 := s.effectiveCo2(greenShare); co2 != nil { s.publish("tariffEffectiveCo2", co2) } } @@ -662,7 +664,8 @@ func (site *Site) update(lp Updater) { } if sitePower, batteryBuffered, err := site.sitePower(totalChargePower, flexiblePower); err == nil { - lp.Update(sitePower, autoCharge, batteryBuffered) + greenShare := site.greenShare() + lp.Update(sitePower, autoCharge, batteryBuffered, greenShare, site.effectivePrice(greenShare), site.effectiveCo2(greenShare)) // ignore negative pvPower values as that means it is not an energy source but consumption homePower := site.gridPower + math.Max(0, site.pvPower) + site.batteryPower - totalChargePower diff --git a/i18n/de.toml b/i18n/de.toml index 285207961..5da23add8 100644 --- a/i18n/de.toml +++ b/i18n/de.toml @@ -122,13 +122,17 @@ pvProduction = "Erzeugung" selfConsumption = "Eigenverbrauch" [main.loadpoint] +avgPrice = "Ø Preis" charged = "Geladen" +co2 = "Ø CO₂" duration = "Dauer" fallbackName = "Ladepunkt" power = "Leistung" +price = "Σ Preis" remaining = "Restzeit" remoteDisabledHard = "{source}: Deaktiviert" remoteDisabledSoft = "{source}: Adaptives PV-Laden deaktiviert" +solar = "Sonne" [main.loadpointSettings] currents = "Ladestrom" @@ -246,20 +250,28 @@ reload = "Erneut laden?" [session] cancel = "Abbrechen" +co2 = "CO₂" +date = "Zeitraum" delete = "Löschen" finished = "Endzeit" +meter = "Zählerstand" meterstart = "Anfangszählerstand" meterstop = "Endzählerstand" odometer = "Kilometerstand" +price = "Preis" started = "Startzeit" title = "Ladevorgang" [sessions] -date = "Zeitraum" +avgPrice = "Ø Preis" +co2 = "Ø CO₂" +date = "Anfang" downloadCsv = "Als CSV herunterladen" energy = "Geladen" loadpoint = "Ladepunkt" +price = "Σ Preis" reallyDelete = "Wollen Sie diesen Ladevorgang wirklich löschen?" +solar = "Sonne" title = "Ladevorgänge" vehicle = "Fahrzeug" diff --git a/i18n/en.toml b/i18n/en.toml index 4486bce05..dd8a15338 100644 --- a/i18n/en.toml +++ b/i18n/en.toml @@ -122,13 +122,17 @@ pvProduction = "Production" selfConsumption = "Self-consumption" [main.loadpoint] +avgPrice = "Ø Price" charged = "Charged" +co2 = "Ø CO₂" duration = "Duration" fallbackName = "Charging point" power = "Power" +price = "Σ Price" remaining = "Remaining" remoteDisabledHard = "{source}: turned off" remoteDisabledSoft = "{source}: turned off adaptive solar-charging" +solar = "Solar" [main.loadpointSettings] currents = "Charging current" @@ -246,20 +250,28 @@ reload = "Reload?" [session] cancel = "Cancel" +co2 = "CO₂" +date = "Period" delete = "Delete" finished = "Finished" +meter = "Meter" meterstart = "Meter start" meterstop = "Meter stop" odometer = "Mileage" +price = "Price" started = "Started" title = "Charging Session" [sessions] -date = "Period" +avgPrice = "Ø Price" +co2 = "Ø CO₂" +date = "Finished" downloadCsv = "Download as CSV" energy = "Charged" loadpoint = "Charging point" +price = "Σ Price" reallyDelete = "Do you really want to delete this session?" +solar = "Solar" title = "Charging Sessions" vehicle = "Vehicle" diff --git a/server/http_session_handler.go b/server/http_session_handler.go index 0a8f22c40..b077aa79e 100644 --- a/server/http_session_handler.go +++ b/server/http_session_handler.go @@ -69,7 +69,10 @@ func sessionHandler(w http.ResponseWriter, r *http.Request) { // prepare data for i, s := range res { - res[i].Odometer = math.Round(s.Odometer*10) / 10 + if s.Odometer != nil { + odo := math.Round(*s.Odometer*10) / 10 + res[i].Odometer = &odo + } } if r.URL.Query().Get("format") == "csv" {
{{ $t("sessions.date") }} {{ $t("sessions.vehicle") }} + {{ $t("sessions.energy") }} - {{ $t("sessions.date") }} + + {{ $t("sessions.solar") }} + + {{ $t("sessions.price") }} + + {{ $t("sessions.avgPrice") }} + + {{ $t("sessions.co2") }}
+ + {{ fmtFullDateTime(new Date(session.finished), true) }} + {{ session.vehicle }} + {{ fmtKWh(session.chargedEnergy * 1e3) }} - - {{ - fmtFullDateTime(new Date(session.created), true) - }} -
- {{ - fmtFullDateTime( - new Date(session.finished), - true - ) - }} +
+ + {{ fmtNumber(session.solarPercentage, 1) }}% - - {{ - fmtFullDateTime( - new Date(session.created), - false - ) - }} -
- {{ - fmtFullDateTime( - new Date(session.finished), - false - ) - }} + - +
+ + {{ fmtMoney(session.price, currency) }} + {{ fmtCurrencySymbol(currency) }} + - + + + {{ fmtPricePerKWh(session.pricePerKWh, currency) }} + + - + + + {{ fmtCo2Medium(session.co2PerKWh) }} + + -