Repository: evcc-io/evcc
Pull request #6103 (2023-02-06 17:16:34)

Powerwall: fix Wh to kWh conversion
Fix https://github.com/evcc-io/evcc/issues/6102


Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 164d6b30..bc07bb11 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -115,7 +115,7 @@ jobs:
     steps:
       - uses: actions/checkout@v3
       - uses: superfly/flyctl-actions/setup-flyctl@master
-      - run: flyctl deploy --remote-only --config packaging/fly.toml
+      - run: flyctl deploy --local-only --config packaging/fly.toml
 
   hassio:
     name: Hassio Addon
diff --git a/assets/js/api.js b/assets/js/api.js
index c84c3008..3ca9d5ca 100644
--- a/assets/js/api.js
+++ b/assets/js/api.js
@@ -15,8 +15,11 @@ const api = axios.create({
 api.interceptors.response.use(
   (response) => response,
   (error) => {
-    const url = error.config.baseURL + error.config.url;
-    const message = `${error.message}: API request failed ${url}`;
+    let message = error.message;
+    if (error.config) {
+      const url = error.config.baseURL + error.config.url;
+      message += `: API request failed ${url}`;
+    }
     window.app.error({ message });
     return Promise.reject(error);
   }
diff --git a/assets/js/components/Energyflow/Energyflow.story.vue b/assets/js/components/Energyflow/Energyflow.story.vue
index 23230034..c8b30c82 100644
--- a/assets/js/components/Energyflow/Energyflow.story.vue
+++ b/assets/js/components/Energyflow/Energyflow.story.vue
@@ -27,6 +27,10 @@ import Energyflow from "./Energyflow.vue";
 				:batteryPower="800"
 				:batterySoc="77"
 				siteTitle="Home"
+				:battery="[
+					{ soc: 44, capacity: 13.3 },
+					{ soc: 82, capacity: 21 },
+				]"
 			/>
 		</Variant>
 		<Variant title="battery charging">
diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue
index 7a263659..8dce4c92 100644
--- a/assets/js/components/Energyflow/Energyflow.vue
+++ b/assets/js/components/Energyflow/Energyflow.vue
@@ -65,12 +65,17 @@
 							:soc="batterySoc"
 							:power="batteryDischarge"
 							:valuesInKw="valuesInKw"
+							:tooltip="batteryTooltip"
 						/>
 						<EnergyflowEntry
 							:name="$t('main.energyflow.gridImport')"
 							icon="powersupply"
 							:power="gridImport"
 							:valuesInKw="valuesInKw"
+							:price="tariffGrid"
+							:currency="currency"
+							:co2="tariffCo2"
+							:tooltip="detailsTooltip(tariffGrid, tariffCo2)"
 						/>
 					</div>
 				</div>
@@ -89,6 +94,10 @@
 							icon="home"
 							:power="homePower"
 							:valuesInKw="valuesInKw"
+							:price="tariffEffectivePrice"
+							:currency="currency"
+							:co2="tariffEffectiveCo2"
+							:tooltip="detailsTooltip(tariffEffectivePrice, tariffEffectiveCo2)"
 						/>
 						<EnergyflowEntry
 							:name="
@@ -100,6 +109,10 @@
 							:vehicleIcons="vehicleIcons"
 							:power="loadpointsPower"
 							:valuesInKw="valuesInKw"
+							:price="tariffEffectivePrice"
+							:currency="currency"
+							:co2="tariffEffectiveCo2"
+							:tooltip="detailsTooltip(tariffEffectivePrice, tariffEffectiveCo2)"
 						/>
 						<EnergyflowEntry
 							v-if="batteryConfigured"
@@ -108,12 +121,16 @@
 							:soc="batterySoc"
 							:power="batteryCharge"
 							:valuesInKw="valuesInKw"
+							:tooltip="batteryTooltip"
 						/>
 						<EnergyflowEntry
 							:name="$t('main.energyflow.pvExport')"
 							icon="powersupply"
 							:power="pvExport"
 							:valuesInKw="valuesInKw"
+							:price="-tariffFeedIn"
+							:currency="currency"
+							:tooltip="detailsTooltip(-tariffFeedIn)"
 						/>
 					</div>
 				</div>
@@ -143,9 +160,16 @@ export default {
 		loadpointsPower: { type: Number, default: 0 },
 		activeLoadpointsCount: { type: Number, default: 0 },
 		batteryConfigured: Boolean,
+		battery: { type: Array },
 		batteryPower: { type: Number, default: 0 },
 		batterySoc: { type: Number, default: 0 },
 		vehicleIcons: { type: Array },
+		tariffGrid: { type: Number },
+		tariffFeedIn: { type: Number },
+		tariffEffectivePrice: { type: Number },
+		tariffCo2: { type: Number },
+		tariffEffectiveCo2: { type: Number },
+		currency: { type: String },
 	},
 	data: () => {
 		return { detailsOpen: false, detailsCompleteHeight: null };
@@ -187,6 +211,16 @@ export default {
 		detailsHeight: function () {
 			return this.detailsOpen ? this.detailsCompleteHeight + "px" : 0;
 		},
+		batteryTooltip() {
+			if (!Array.isArray(this.battery)) {
+				return;
+			}
+			return this.battery.map(({ soc, capacity }) => {
+				const energy = this.fmtKWh((capacity / 100) * soc * 1e3, true, false, 1);
+				const total = this.fmtKWh(capacity * 1e3, true, true, 1);
+				return this.$t("main.energyflow.batteryTooltip", { energy, total, soc });
+			});
+		},
 	},
 	mounted() {
 		window.addEventListener("resize", this.updateHeight);
@@ -199,6 +233,16 @@ export default {
 		window.removeEventListener("resize", this.updateHeight);
 	},
 	methods: {
+		detailsTooltip(price, co2) {
+			const result = [];
+			if (co2 !== undefined) {
+				result.push(`${this.fmtCo2Long(co2)}`);
+			}
+			if (price !== undefined) {
+				result.push(`${this.fmtPricePerKWh(price, this.currency)}`);
+			}
+			return result;
+		},
 		kw: function (watt) {
 			return this.fmtKw(watt, this.valuesInKw);
 		},
diff --git a/assets/js/components/Energyflow/EnergyflowEntry.vue b/assets/js/components/Energyflow/EnergyflowEntry.vue
index 3cc8fb17..1efd3290 100644
--- a/assets/js/components/Energyflow/EnergyflowEntry.vue
+++ b/assets/js/components/Energyflow/EnergyflowEntry.vue
@@ -5,10 +5,22 @@
 			<VehicleIcon v-else-if="isVehicle" :names="vehicleIcons" />
 			<component :is="`shopicon-regular-${icon}`" v-else></component>
 		</span>
-		<span class="text-nowrap flex-grow-1 ms-3">{{ name }}</span>
-		<span class="text-end text-nowrap ps-1 fw-bold"
-			><span v-if="hasSoc">{{ soc }}% / </span>
-			<AnimatedNumber :to="power" :format="kw" />
+		<span class="text-nowrap flex-grow-1 ms-3">
+			{{ name }}
+		</span>
+		<span class="text-end text-nowrap ps-1 fw-bold d-flex">
+			<div
+				ref="details"
+				class="evcc-gray fw-normal"
+				data-bs-toggle="tooltip"
+				title=" "
+				@click.stop=""
+			>
+				<span v-if="showPrice()"><AnimatedNumber :to="price" :format="fmtPrice" /></span>
+				<span v-if="showCo2()"><AnimatedNumber :to="co2" :format="fmtCo2Short" /></span>
+				<span v-if="hasSoc">{{ soc }}%</span>
+			</div>
+			<AnimatedNumber :to="power" :format="kw" class="power" />
 		</span>
 	</div>
 </template>
@@ -17,10 +29,12 @@
 import "@h2d2/shopicons/es/regular/powersupply";
 import "@h2d2/shopicons/es/regular/sun";
 import "@h2d2/shopicons/es/regular/home";
+import Tooltip from "bootstrap/js/dist/tooltip";
 import BatteryIcon from "./BatteryIcon.vue";
 import formatter from "../../mixins/formatter";
 import AnimatedNumber from "../AnimatedNumber.vue";
 import VehicleIcon from "../VehicleIcon";
+import { showGridPrice, showGridCo2 } from "../../gridDetails";
 
 export default {
 	name: "EnergyflowEntry",
@@ -31,8 +45,15 @@ export default {
 		icon: { type: String },
 		power: { type: Number },
 		soc: { type: Number },
+		price: { type: Number },
+		co2: { type: Number },
 		valuesInKw: { type: Boolean },
 		vehicleIcons: { type: Array },
+		currency: { type: String },
+		tooltip: { type: Array },
+	},
+	data() {
+		return { tooltipInstance: null };
 	},
 	computed: {
 		active: function () {
@@ -48,10 +69,39 @@ export default {
 			return this.isBattery && !isNaN(this.soc);
 		},
 	},
+	watch: {
+		tooltip(newVal, oldVal) {
+			if (JSON.stringify(newVal) !== JSON.stringify(oldVal)) {
+				this.updateTooltip();
+			}
+		},
+	},
+	mounted: function () {
+		this.updateTooltip();
+	},
 	methods: {
+		showPrice() {
+			return showGridPrice() && !isNaN(this.price);
+		},
+		showCo2() {
+			return showGridCo2() && !isNaN(this.co2);
+		},
 		kw: function (watt) {
 			return this.fmtKw(watt, this.valuesInKw);
 		},
+		fmtPrice: function (price) {
+			return this.fmtPricePerKWh(price, this.currency, true);
+		},
+		updateTooltip: function () {
+			if (!Array.isArray(this.tooltip) || !this.tooltip.length) {
+				return;
+			}
+			if (!this.tooltipInstance) {
+				this.tooltipInstance = new Tooltip(this.$refs.details, { html: true });
+			}
+			const html = `<div class="text-end">${this.tooltip.join("<br/>")}</div>`;
+			this.tooltipInstance.setContent({ ".tooltip-inner": html });
+		},
 	},
 };
 </script>
@@ -59,4 +109,7 @@ export default {
 .entry {
 	transition: color var(--evcc-transition-medium) linear;
 }
+.power {
+	min-width: 75px;
+}
 </style>
diff --git a/assets/js/components/GlobalSettingsModal.vue b/assets/js/components/GlobalSettingsModal.vue
index b04c0534..c03a3dfb 100644
--- a/assets/js/components/GlobalSettingsModal.vue
+++ b/assets/js/components/GlobalSettingsModal.vue
@@ -63,6 +63,23 @@
 									"
 								/>
 							</FormRow>
+							<FormRow
+								id="settingsGridDetails"
+								:label="$t('settings.gridDetails.label')"
+							>
+								<SelectGroup
+									id="settingsGridDetails"
+									v-model="gridDetails"
+									class="w-100"
+									:options="
+										GRID_DETAILS.map((value) => ({
+											value,
+											name: $t(`settings.gridDetails.${value}`),
+											disabled: isDisabled(value),
+										}))
+									"
+								/>
+							</FormRow>
 							<FormRow id="telemetryEnabled" :label="$t('settings.telemetry.label')">
 								<TelemetrySettings :sponsor="sponsor" class="mt-1 mb-0" />
 							</FormRow>
@@ -81,20 +98,25 @@ import SelectGroup from "./SelectGroup.vue";
 import { getLocalePreference, setLocalePreference, LOCALES, removeLocalePreference } from "../i18n";
 import { getThemePreference, setThemePreference, THEMES } from "../theme";
 import { getUnits, setUnits, UNITS } from "../units";
+import { getGridDetails, setGridDetails, GRID_DETAILS } from "../gridDetails";
 
 export default {
 	name: "GlobalSettingsModal",
 	components: { TelemetrySettings, FormRow, SelectGroup },
 	props: {
 		sponsor: String,
+		hasPrice: Boolean,
+		hasCo2: Boolean,
 	},
 	data: function () {
 		return {
 			theme: getThemePreference(),
 			language: getLocalePreference() || "",
 			unit: getUnits(),
+			gridDetails: getGridDetails(),
 			THEMES,
 			UNITS,
+			GRID_DETAILS,
 		};
 	},
 	computed: {
@@ -111,6 +133,9 @@ export default {
 		unit(value) {
 			setUnits(value);
 		},
+		gridDetails(value) {
+			setGridDetails(value);
+		},
 		theme(value) {
 			setThemePreference(value);
 		},
@@ -123,6 +148,11 @@ export default {
 			}
 		},
 	},
+	methods: {
+		isDisabled(option) {
+			return (option === "co2" && !this.hasCo2) || (option === "price" && !this.hasPrice);
+		},
+	},
 };
 </script>
 <style scoped>
diff --git a/assets/js/components/SelectGroup.vue b/assets/js/components/SelectGroup.vue
index 9fc6a6da..7c07fcdb 100644
--- a/assets/js/components/SelectGroup.vue
+++ b/assets/js/components/SelectGroup.vue
@@ -7,6 +7,7 @@
 			type="button"
 			class="btn btn-sm flex-grow-1 flex-shrink-1"
 			:class="{ active: option.value === modelValue }"
+			:disabled="option.disabled"
 			@click="$emit('update:modelValue', option.value)"
 		>
 			{{ option.name }}
diff --git a/assets/js/components/Site.vue b/assets/js/components/Site.vue
index 5f54a1e3..29ed64a5 100644
--- a/assets/js/components/Site.vue
+++ b/assets/js/components/Site.vue
@@ -61,6 +61,7 @@ export default {
 		batteryConfigured: Boolean,
 		batteryPower: Number,
 		batterySoc: Number,
+		battery: Array,
 		gridCurrents: Array,
 		prioritySoc: Number,
 		siteTitle: String,
@@ -68,7 +69,6 @@ export default {
 
 		auth: Object,
 
-		// footer
 		currency: String,
 		savingsAmount: Number,
 		savingsEffectivePrice: Number,
@@ -77,8 +77,12 @@ export default {
 		savingsSelfConsumptionPercent: Number,
 		savingsSince: String,
 		savingsTotalCharged: Number,
+		greenShare: Number,
 		tariffFeedIn: Number,
 		tariffGrid: Number,
+		tariffEffectivePrice: Number,
+		tariffCo2: Number,
+		tariffEffectiveCo2: Number,
 
 		availableVersion: String,
 		releaseNotes: String,
@@ -113,6 +117,12 @@ export default {
 			const vehicleLogins = this.auth ? this.auth.vehicles : {};
 			return { vehicleLogins, ...this.collectProps(TopNavigation) };
 		},
+		hasPrice: function () {
+			return !isNaN(this.tariffGrid);
+		},
+		hasCo2: function () {
+			return !isNaN(this.tariffCo2);
+		},
 		showParkingLot: function () {
 			// work in progess
 			return false;
diff --git a/assets/js/components/TargetCharge.vue b/assets/js/components/TargetCharge.vue
index 4be6c15e..832a75f2 100644
--- a/assets/js/components/TargetCharge.vue
+++ b/assets/js/components/TargetCharge.vue
@@ -242,16 +242,17 @@ export default {
 			if (
 				this.isModalVisible &&
 				!this.timeInThePast &&
-				(this.targetEnergy || this.targetSoc)
+				(this.targetEnergy || this.targetSoc) &&
+				!isNaN(this.selectedTargetTime)
 			) {
 				try {
 					const opts = {
 						params: { targetTime: this.selectedTargetTime },
 					};
 					this.plan = (
-						await api.get(`/loadpoints/${this.id}/target/plan`, opts)
+						await api.get(`loadpoints/${this.id}/target/plan`, opts)
 					).data.result;
-					this.tariff = (await api.get(`/tariff/planner`)).data.result;
+					this.tariff = (await api.get(`tariff/planner`)).data.result;
 				} catch (e) {
 					console.error(e);
 				}
diff --git a/assets/js/components/TargetChargePlan.vue b/assets/js/components/TargetChargePlan.vue
index ad0626ef..895089ee 100644
--- a/assets/js/components/TargetChargePlan.vue
+++ b/assets/js/components/TargetChargePlan.vue
@@ -94,9 +94,7 @@ export default {
 			if (price === undefined) {
 				return this.$t("main.targetChargePlan.unknownPrice");
 			}
-			return this.isCo2
-				? `${Math.round(price)} g/kWh`
-				: this.fmtPricePerKWh(price, this.unit);
+			return this.isCo2 ? this.fmtCo2Medium(price) : this.fmtPricePerKWh(price, this.unit);
 		},
 		activeSlot() {
 			return this.slots[this.activeIndex];
diff --git a/assets/js/components/TopNavigation.vue b/assets/js/components/TopNavigation.vue
index dd0919dc..78cae574 100644
--- a/assets/js/components/TopNavigation.vue
+++ b/assets/js/components/TopNavigation.vue
@@ -92,7 +92,7 @@
 				</a>
 			</li>
 		</ul>
-		<GlobalSettingsModal :sponsor="sponsor" />
+		<GlobalSettingsModal v-bind="globalSettingsModalProps" />
 	</div>
 </template>
 
@@ -104,12 +104,14 @@ import "@h2d2/shopicons/es/regular/moonstars";
 import "@h2d2/shopicons/es/regular/menu";
 import "@h2d2/shopicons/es/regular/newtab";
 import GlobalSettingsModal from "./GlobalSettingsModal.vue";
+import collector from "../mixins/collector";
 
 import baseAPI from "../baseapi";
 
 export default {
 	name: "TopNavigation",
 	components: { GlobalSettingsModal },
+	mixins: [collector],
 	props: {
 		vehicleLogins: {
 			type: Object,
@@ -118,8 +120,13 @@ export default {
 			},
 		},
 		sponsor: String,
+		hasPrice: Boolean,
+		hasCo2: Boolean,
 	},
 	computed: {
+		globalSettingsModalProps: function () {
+			return this.collectProps(GlobalSettingsModal);
+		},
 		logoutCount() {
 			return this.providerLogins.filter((login) => !login.loggedIn).length;
 		},
diff --git a/assets/js/gridDetails.js b/assets/js/gridDetails.js
new file mode 100644
index 00000000..8c16e3cb
--- /dev/null
+++ b/assets/js/gridDetails.js
@@ -0,0 +1,22 @@
+import settings from "./settings";
+
+const NONE = "none";
+const PRICE = "price";
+const CO2 = "co2";
+export const GRID_DETAILS = [NONE, PRICE, CO2];
+
+export function getGridDetails() {
+  return GRID_DETAILS.includes(settings.gridDetails) ? settings.gridDetails : NONE;
+}
+
+export function showGridPrice() {
+  return settings.gridDetails === PRICE;
+}
+
+export function showGridCo2() {
+  return settings.gridDetails === CO2;
+}
+
+export function setGridDetails(value) {
+  settings.gridDetails = value;
+}
diff --git a/assets/js/mixins/formatter.js b/assets/js/mixins/formatter.js
index 974ba58c..3eb4c451 100644
--- a/assets/js/mixins/formatter.js
+++ b/assets/js/mixins/formatter.js
@@ -33,7 +33,7 @@ export default {
       }).format(value)}${unit}`;
     },
     fmtKWh: function (watt, kw, withUnit, digits) {
-      return this.fmtKw(watt, kw, withUnit, digits) + "h";
+      return this.fmtKw(watt, kw, withUnit, digits) + (withUnit ? "h" : "");
     },
     fmtNumber: function (number, decimals) {
       return new Intl.NumberFormat(this.$i18n.locale, {
@@ -42,6 +42,15 @@ export default {
         maximumFractionDigits: decimals,
       }).format(number);
     },
+    fmtCo2Short: function (gramms) {
+      return `${this.fmtNumber(gramms, 0)} g`;
+    },
+    fmtCo2Medium: function (gramms) {
+      return `${this.fmtNumber(gramms, 0)} g/kWh`;
+    },
+    fmtCo2Long: function (gramms) {
+      return `${this.fmtNumber(gramms, 0)} gCO₂e/kWh`;
+    },
     fmtUnit: function (val) {
       return Math.abs(val) >= this.fmtLimit ? "k" : "";
     },
@@ -182,7 +191,7 @@ export default {
       const symbols = { EUR: "€", USD: "$" };
       return symbols[currency] || currency;
     },
-    fmtPricePerKWh: function (amout = 0, currency = "EUR") {
+    fmtPricePerKWh: function (amout = 0, currency = "EUR", short = false) {
       let unit = currency;
       let value = amout;
       let maximumFractionDigits = 3;
@@ -194,7 +203,7 @@ export default {
       return `${new Intl.NumberFormat(this.$i18n.locale, {
         style: "decimal",
         maximumFractionDigits,
-      }).format(value)} ${unit}/kWh`;
+      }).format(value)} ${unit}${short ? "" : "/kWh"}`;
     },
     fmtTimeAgo: function (elapsed) {
       const units = {
diff --git a/assets/js/settings.js b/assets/js/settings.js
index 195676bf..90314847 100644
--- a/assets/js/settings.js
+++ b/assets/js/settings.js
@@ -4,6 +4,7 @@ const SETTINGS_LOCALE = "settings_locale";
 const SETTINGS_THEME = "settings_theme";
 const SETTINGS_UNIT = "settings_unit";
 const SETTINGS_ENERGYFLOW_DETAILS = "settings_energyflow_details";
+const SETTINGS_GRID_DETAILS = "settings_grid_details";
 
 function read(key) {
   return window.localStorage[key];
@@ -38,12 +39,14 @@ const settings = reactive({
   locale: read(SETTINGS_LOCALE),
   theme: read(SETTINGS_THEME),
   unit: read(SETTINGS_UNIT),
+  gridDetails: read(SETTINGS_GRID_DETAILS),
   energyflowDetails: readBool(SETTINGS_ENERGYFLOW_DETAILS),
 });
 
 watch(() => settings.locale, save(SETTINGS_LOCALE));
 watch(() => settings.theme, save(SETTINGS_THEME));
 watch(() => settings.unit, save(SETTINGS_UNIT));
+watch(() => settings.gridDetails, save(SETTINGS_GRID_DETAILS));
 watch(() => settings.energyflowDetails, saveBool(SETTINGS_ENERGYFLOW_DETAILS));
 
 export default settings;
diff --git a/cmd/demo.yaml b/cmd/demo.yaml
index e32909db..1415f2ba 100644
--- a/cmd/demo.yaml
+++ b/cmd/demo.yaml
@@ -60,6 +60,7 @@ meters:
         if (state.batterySoc < 10) state.batterySoc = 90;
         if (state.batterySoc > 90) state.batterySoc = 10;
         state.batterySoc;
+    capacity: 13.4
 
   - name: meter_charger_1
     type: custom
diff --git a/core/const.go b/core/const.go
index 5f07645e..7fd794ba 100644
--- a/core/const.go
+++ b/core/const.go
@@ -1,6 +1,8 @@
 package core
 
 const (
+	title = "title" // loadpoint title
+
 	phasesConfigured = "phasesConfigured" // configured phases (1/3, 0 for auto on 1p3p chargers, nil for plain chargers)
 	phasesEnabled    = "phasesEnabled"    // enabled phases (1/3)
 	phasesActive     = "phasesActive"     // active phases as used by vehicle (1/2/3)
@@ -16,9 +18,13 @@ const (
 	vehiclePresent   = "vehiclePresent"   // vehicle detected
 	vehicleTitle     = "vehicleTitle"     // vehicle title
 
-	minSoc             = "minSoc"             // min soc goal
-	targetSoc          = "targetSoc"          // target charging soc goal
-	targetTime         = "targetTime"         // target charging finish time goal
-	planActive         = "planActive"         // target charging plan has determined current slot to be an active slot
-	planProjectedStart = "planProjectedStart" // target charging plan start time (earliest slot)
+	minCurrent              = "minCurrent"              // charger min current
+	maxCurrent              = "maxCurrent"              // charger max current
+	chargeRemainingDuration = "chargeRemainingDuration" // charge remaining duration
+	minSoc                  = "minSoc"                  // min soc goal
+	targetEnergy            = "targetEnergy"            // target charging energy goal
+	targetSoc               = "targetSoc"               // target charging soc goal
+	targetTime              = "targetTime"              // target charging finish time goal
+	planActive              = "planActive"              // target charging plan has determined current slot to be an active slot
+	planProjectedStart      = "planProjectedStart"      // target charging plan start time (earliest slot)
 )
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 2883d949..47dc2294 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -5,7 +5,6 @@ import (
 	"fmt"
 	"math"
 	"reflect"
-	"regexp"
 	"strings"
 	"sync"
 	"time"
@@ -17,7 +16,6 @@ import (
 	"github.com/evcc-io/evcc/core/planner"
 	"github.com/evcc-io/evcc/core/soc"
 	"github.com/evcc-io/evcc/core/wrapper"
-	"github.com/evcc-io/evcc/provider"
 	"github.com/evcc-io/evcc/push"
 	"github.com/evcc-io/evcc/util"
 
@@ -47,12 +45,8 @@ const (
 
 	timerInactive = "inactive"
 
-	minActiveCurrent      = 1.0 // minimum current at which a phase is treated as active
-	minActiveVoltage      = 208 // minimum voltage at which a phase is treated as active
-	vehicleDetectInterval = 1 * time.Minute
-	vehicleDetectDuration = 10 * time.Minute
-
-	smallSlotDuration = 10 * time.Minute // small planner slot duration we might ignore
+	minActiveCurrent = 1.0 // minimum current at which a phase is treated as active
+	minActiveVoltage = 208 // minimum voltage at which a phase is treated as active
 
 	guardGracePeriod = 10 * time.Second // allow out of sync during this timespan
 )
@@ -555,9 +549,9 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
 	_ = lp.bus.Subscribe(evVehicleSoc, lp.evVehicleSocProgressHandler)
 
 	// publish initial values
-	lp.publish("title", lp.Title())
-	lp.publish("minCurrent", lp.MinCurrent)
-	lp.publish("maxCurrent", lp.MaxCurrent)
+	lp.publish(title, lp.Title())
+	lp.publish(minCurrent, lp.MinCurrent)
+	lp.publish(maxCurrent, lp.MaxCurrent)
 
 	lp.setConfiguredPhases(lp.ConfiguredPhases)
 	lp.publish(phasesEnabled, lp.phases)
@@ -793,294 +787,6 @@ func (lp *Loadpoint) remoteControlled(demand loadpoint.RemoteDemand) bool {
 	return lp.remoteDemand == demand
 }
 
-// setVehicleIdentifier updated the vehicle id as read from the charger
-func (lp *Loadpoint) setVehicleIdentifier(id string) {
-	if lp.vehicleIdentifier != id {
-		lp.vehicleIdentifier = id
-		lp.publish("vehicleIdentity", id)
-	}
-}
-
-// identifyVehicle reads vehicle identification from charger
-func (lp *Loadpoint) identifyVehicle() {
-	identifier, ok := lp.charger.(api.Identifier)
-	if !ok {
-		return
-	}
-
-	id, err := identifier.Identify()
-	if err != nil {
-		lp.log.ERROR.Println("charger vehicle id:", err)
-		return
-	}
-
-	if lp.vehicleIdentifier == id {
-		return
-	}
-
-	// vehicle found or removed
-	lp.setVehicleIdentifier(id)
-
-	if id != "" {
-		lp.log.DEBUG.Println("charger vehicle id:", id)
-
-		if vehicle := lp.selectVehicleByID(id); vehicle != nil {
-			lp.stopVehicleDetection()
-			lp.setActiveVehicle(vehicle)
-		}
-	}
-}
-
-// selectVehicleByID selects the vehicle with the given ID
-func (lp *Loadpoint) selectVehicleByID(id string) api.Vehicle {
-	vehicles := lp.coordinatedVehicles()
-
-	// find exact match
-	for _, vehicle := range vehicles {
-		for _, vid := range vehicle.Identifiers() {
-			if strings.EqualFold(id, vid) {
-				return vehicle
-			}
-		}
-	}
-
-	// find placeholder match
-	for _, vehicle := range vehicles {
-		for _, vid := range vehicle.Identifiers() {
-			// case insensitive match
-			re, err := regexp.Compile("(?i)" + strings.ReplaceAll(vid, "*", ".*?"))
-			if err != nil {
-				lp.log.ERROR.Printf("vehicle id: %v", err)
-				continue
-			}
-
-			if re.MatchString(id) {
-				return vehicle
-			}
-		}
-	}
-
-	return nil
-}
-
-// setActiveVehicle assigns currently active vehicle, configures soc estimator
-// and adds an odometer task
-func (lp *Loadpoint) setActiveVehicle(vehicle api.Vehicle) {
-	lp.Lock()
-
-	if lp.vehicle == vehicle {
-		lp.Unlock()
-		return
-	}
-
-	from := "unknown"
-	if lp.vehicle != nil {
-		lp.coordinator.Release(lp.vehicle)
-		from = lp.vehicle.Title()
-	}
-	to := "unknown"
-	if vehicle != nil {
-		lp.coordinator.Acquire(vehicle)
-		to = vehicle.Title()
-	}
-	lp.log.INFO.Printf("vehicle updated: %s -> %s", from, to)
-
-	lp.vehicle = vehicle
-
-	// reset minSoc and targetSoc before change
-	lp.setMinSoc(0)
-	lp.setTargetSoc(100)
-
-	// reset target energy
-	lp.setTargetEnergy(0)
-
-	// unblock api
-	lp.Unlock()
-
-	if vehicle != nil {
-		lp.socUpdated = time.Time{}
-
-		// resolve optional config
-		var estimate bool
-		if lp.Soc.Estimate == nil || *lp.Soc.Estimate {
-			estimate = true
-		}
-		lp.socEstimator = soc.NewEstimator(lp.log, lp.charger, vehicle, estimate)
-
-		lp.publish(vehiclePresent, true)
-		lp.publish(vehicleTitle, lp.vehicle.Title())
-		lp.publish(vehicleIcon, lp.vehicle.Icon())
-		lp.publish(vehicleCapacity, lp.vehicle.Capacity())
-
-		lp.applyAction(vehicle.OnIdentified())
-		lp.addTask(lp.vehicleOdometer)
-
-		lp.progress.Reset()
-	} else {
-		lp.socEstimator = nil
-
-		lp.publish(vehiclePresent, false)
-		lp.publish(vehicleTitle, "")
-		lp.publish(vehicleIcon, "")
-		lp.publish(vehicleCapacity, int64(0))
-		lp.publish(vehicleOdometer, 0.0)
-	}
-
-	// re-publish vehicle settings
-	lp.publish(phasesActive, lp.activePhases())
-	lp.unpublishVehicle()
-
-	lp.updateSession(func(session *db.Session) {
-		var title string
-		if lp.vehicle != nil {
-			title = lp.vehicle.Title()
-		}
-
-		lp.session.Vehicle = title
-	})
-}
-
-func (lp *Loadpoint) wakeUpVehicle() {
-	// charger
-	if c, ok := lp.charger.(api.Resurrector); ok {
-		if err := c.WakeUp(); err != nil {
-			lp.log.ERROR.Printf("wake-up charger: %v", err)
-		}
-		return
-	}
-
-	// vehicle
-	if vs, ok := lp.vehicle.(api.Resurrector); ok {
-		if err := vs.WakeUp(); err != nil {
-			lp.log.ERROR.Printf("wake-up vehicle: %v", err)
-		}
-	}
-}
-
-// unpublishVehicle resets published vehicle data
-func (lp *Loadpoint) unpublishVehicle() {
-	lp.vehicleSoc = 0
-
-	lp.publish(vehicleSoc, 0.0)
-	lp.publish(vehicleRange, int64(0))
-	lp.publish(vehicleTargetSoc, 0.0)
-
-	lp.setRemainingEnergy(0)
-	lp.setRemainingDuration(0)
-
-	lp.vehiclePublishFeature(api.Offline)
-}
-
-// vehicleHasFeature checks availability of vehicle feature
-func (lp *Loadpoint) vehicleHasFeature(f api.Feature) bool {
-	v, ok := lp.vehicle.(api.FeatureDescriber)
-	if ok {
-		ok = v.Has(f)
-	}
-	return ok
-}
-
-// vehiclePublishFeature availability of vehicle features
-func (lp *Loadpoint) vehiclePublishFeature(f api.Feature) {
-	lp.publish("vehicleFeature"+f.String(), lp.vehicleHasFeature(f))
-}
-
-// vehicleUnidentified returns true if there are associated vehicles and detection is running.
-// It will also reset the api cache at regular intervals.
-// Detection is stopped after maximum duration and the "guest vehicle" message dispatched.
-func (lp *Loadpoint) vehicleUnidentified() bool {
-	if lp.vehicle != nil || lp.vehicleDetect.IsZero() || len(lp.coordinatedVehicles()) == 0 {
-		return false
-	}
-
-	// stop detection
-	if lp.clock.Since(lp.vehicleDetect) > vehicleDetectDuration {
-		lp.stopVehicleDetection()
-		lp.pushEvent(evVehicleUnidentified)
-		return false
-	}
-
-	// request vehicle api refresh while waiting to identify
-	select {
-	case <-lp.vehicleDetectTicker.C:
-		lp.log.DEBUG.Println("vehicle api refresh")
-		provider.ResetCached()
-	default:
-	}
-
-	return true
-}
-
-// vehicleDefaultOrDetect will assign and update default vehicle or start detection
-func (lp *Loadpoint) vehicleDefaultOrDetect() {
-	if lp.defaultVehicle != nil {
-		if lp.vehicle != lp.defaultVehicle {
-			lp.setActiveVehicle(lp.defaultVehicle)
-		} else {
-			// default vehicle is already active, update odometer anyway
-			// need to do this here since setActiveVehicle would short-circuit
-			lp.addTask(lp.vehicleOdometer)
-		}
-	} else if len(lp.coordinatedVehicles()) > 0 && lp.connected() {
-		lp.startVehicleDetection()
-	}
-}
-
-// startVehicleDetection reset connection timer and starts api refresh timer
-func (lp *Loadpoint) startVehicleDetection() {
-	// flush all vehicles before detection starts
-	lp.log.DEBUG.Println("vehicle api refresh")
-	provider.ResetCached()
-
-	lp.vehicleDetect = lp.clock.Now()
-	lp.vehicleDetectTicker = lp.clock.Ticker(vehicleDetectInterval)
-	lp.publish(vehicleDetectionActive, true)
-}
-
-// stopVehicleDetection expires the connection timer and ticker
-func (lp *Loadpoint) stopVehicleDetection() {
-	lp.vehicleDetect = time.Time{}
-	if lp.vehicleDetectTicker != nil {
-		lp.vehicleDetectTicker.Stop()
-	}
-	lp.publish(vehicleDetectionActive, false)
-}
-
-// identifyVehicleByStatus validates if the active vehicle is still connected to the loadpoint
-func (lp *Loadpoint) identifyVehicleByStatus() {
-	if len(lp.coordinatedVehicles()) == 0 {
-		return
-	}
-
-	if vehicle := lp.coordinator.IdentifyVehicleByStatus(); vehicle != nil {
-		lp.stopVehicleDetection()
-		lp.setActiveVehicle(vehicle)
-		return
-	}
-
-	// remove previous vehicle if status was not confirmed
-	if _, ok := lp.vehicle.(api.ChargeState); ok {
-		lp.setActiveVehicle(nil)
-	}
-}
-
-// vehicleOdometer updates odometer
-func (lp *Loadpoint) vehicleOdometer() {
-	if vs, ok := lp.vehicle.(api.VehicleOdometer); ok {
-		if odo, err := vs.Odometer(); err == nil {
-			lp.log.DEBUG.Printf("vehicle odometer: %.0fkm", odo)
-			lp.publish(vehicleOdometer, odo)
-
-			// update session once odometer is read
-			lp.updateSession(func(session *db.Session) {
-				session.Odometer = odo
-			})
-		} else {
-			lp.log.ERROR.Printf("vehicle odometer: %v", err)
-		}
-	}
-}
-
 // statusEvents converts the observed charger status change into a logical sequence of events
 func statusEvents(prevStatus, status api.ChargeStatus) []string {
 	res := make([]string, 0, 2)
diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go
index 5eb44c78..04a15c53 100644
--- a/core/loadpoint_api.go
+++ b/core/loadpoint_api.go
@@ -86,7 +86,7 @@ func (lp *Loadpoint) GetTargetEnergy() float64 {
 // setTargetEnergy sets loadpoint charge target energy (no mutex)
 func (lp *Loadpoint) setTargetEnergy(energy float64) {
 	lp.targetEnergy = energy
-	lp.publish("targetEnergy", energy)
+	lp.publish(targetEnergy, energy)
 }
 
 // SetTargetEnergy sets loadpoint charge target energy
@@ -301,7 +301,7 @@ func (lp *Loadpoint) SetMinCurrent(current float64) {
 
 	if current != lp.MinCurrent {
 		lp.MinCurrent = current
-		lp.publish("minCurrent", lp.MinCurrent)
+		lp.publish(minCurrent, lp.MinCurrent)
 	}
 }
 
@@ -321,7 +321,7 @@ func (lp *Loadpoint) SetMaxCurrent(current float64) {
 
 	if current != lp.MaxCurrent {
 		lp.MaxCurrent = current
-		lp.publish("maxCurrent", lp.MaxCurrent)
+		lp.publish(maxCurrent, lp.MaxCurrent)
 	}
 }
 
@@ -343,10 +343,10 @@ func (lp *Loadpoint) SetRemainingDuration(chargeRemainingDuration time.Duration)
 }
 
 // setRemainingDuration sets the estimated remaining charging duration (no mutex)
-func (lp *Loadpoint) setRemainingDuration(chargeRemainingDuration time.Duration) {
-	if lp.chargeRemainingDuration != chargeRemainingDuration {
-		lp.chargeRemainingDuration = chargeRemainingDuration
-		lp.publish("chargeRemainingDuration", chargeRemainingDuration)
+func (lp *Loadpoint) setRemainingDuration(remainingDuration time.Duration) {
+	if lp.chargeRemainingDuration != remainingDuration {
+		lp.chargeRemainingDuration = remainingDuration
+		lp.publish(chargeRemainingDuration, remainingDuration)
 	}
 }
 
diff --git a/core/loadpoint_plan.go b/core/loadpoint_plan.go
index 4997c44f..310cfe62 100644
--- a/core/loadpoint_plan.go
+++ b/core/loadpoint_plan.go
@@ -9,6 +9,11 @@ import (
 	"golang.org/x/exp/slices"
 )
 
+const (
+	smallSlotDuration = 10 * time.Minute // small planner slot duration we might ignore
+	smallGapDuration  = 30 * time.Minute // small gap duration between planner slots we might ignore
+)
+
 // setPlanActive updates plan active flag
 func (lp *Loadpoint) setPlanActive(active bool) {
 	if !active {
@@ -138,7 +143,7 @@ func (lp *Loadpoint) plannerActive() (active bool) {
 		case requiredDuration < 30*time.Minute:
 			lp.log.DEBUG.Printf("continuing for remaining %v", requiredDuration.Round(time.Second))
 			return true
-		case lp.clock.Until(planStart) < smallSlotDuration:
+		case lp.clock.Until(planStart) < smallGapDuration:
 			lp.log.DEBUG.Printf("plan will re-start shortly, continuing for remaining %v", lp.clock.Until(planStart).Round(time.Second))
 			return true
 		}
diff --git a/core/loadpoint_vehicle.go b/core/loadpoint_vehicle.go
new file mode 100644
index 00000000..0b9bc3d6
--- /dev/null
+++ b/core/loadpoint_vehicle.go
@@ -0,0 +1,305 @@
+package core
+
+import (
+	"regexp"
+	"strings"
+	"time"
+
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/core/db"
+	"github.com/evcc-io/evcc/core/soc"
+	"github.com/evcc-io/evcc/provider"
+)
+
+const (
+	vehicleDetectInterval = 1 * time.Minute
+	vehicleDetectDuration = 10 * time.Minute
+)
+
+// setVehicleIdentifier updated the vehicle id as read from the charger
+func (lp *Loadpoint) setVehicleIdentifier(id string) {
+	if lp.vehicleIdentifier != id {
+		lp.vehicleIdentifier = id
+		lp.publish("vehicleIdentity", id)
+	}
+}
+
+// identifyVehicle reads vehicle identification from charger
+func (lp *Loadpoint) identifyVehicle() {
+	identifier, ok := lp.charger.(api.Identifier)
+	if !ok {
+		return
+	}
+
+	id, err := identifier.Identify()
+	if err != nil {
+		lp.log.ERROR.Println("charger vehicle id:", err)
+		return
+	}
+
+	if lp.vehicleIdentifier == id {
+		return
+	}
+
+	// vehicle found or removed
+	lp.setVehicleIdentifier(id)
+
+	if id != "" {
+		lp.log.DEBUG.Println("charger vehicle id:", id)
+
+		if vehicle := lp.selectVehicleByID(id); vehicle != nil {
+			lp.stopVehicleDetection()
+			lp.setActiveVehicle(vehicle)
+		}
+	}
+}
+
+// selectVehicleByID selects the vehicle with the given ID
+func (lp *Loadpoint) selectVehicleByID(id string) api.Vehicle {
+	vehicles := lp.coordinatedVehicles()
+
+	// find exact match
+	for _, vehicle := range vehicles {
+		for _, vid := range vehicle.Identifiers() {
+			if strings.EqualFold(id, vid) {
+				return vehicle
+			}
+		}
+	}
+
+	// find placeholder match
+	for _, vehicle := range vehicles {
+		for _, vid := range vehicle.Identifiers() {
+			// case insensitive match
+			re, err := regexp.Compile("(?i)" + strings.ReplaceAll(vid, "*", ".*?"))
+			if err != nil {
+				lp.log.ERROR.Printf("vehicle id: %v", err)
+				continue
+			}
+
+			if re.MatchString(id) {
+				return vehicle
+			}
+		}
+	}
+
+	return nil
+}
+
+// setActiveVehicle assigns currently active vehicle, configures soc estimator
+// and adds an odometer task
+func (lp *Loadpoint) setActiveVehicle(vehicle api.Vehicle) {
+	lp.Lock()
+
+	if lp.vehicle == vehicle {
+		lp.Unlock()
+		return
+	}
+
+	from := "unknown"
+	if lp.vehicle != nil {
+		lp.coordinator.Release(lp.vehicle)
+		from = lp.vehicle.Title()
+	}
+	to := "unknown"
+	if vehicle != nil {
+		lp.coordinator.Acquire(vehicle)
+		to = vehicle.Title()
+	}
+	lp.log.INFO.Printf("vehicle updated: %s -> %s", from, to)
+
+	lp.vehicle = vehicle
+
+	// reset minSoc and targetSoc before change
+	lp.setMinSoc(0)
+	lp.setTargetSoc(100)
+
+	// reset target energy
+	lp.setTargetEnergy(0)
+
+	// unblock api
+	lp.Unlock()
+
+	if vehicle != nil {
+		lp.socUpdated = time.Time{}
+
+		// resolve optional config
+		var estimate bool
+		if lp.Soc.Estimate == nil || *lp.Soc.Estimate {
+			estimate = true
+		}
+		lp.socEstimator = soc.NewEstimator(lp.log, lp.charger, vehicle, estimate)
+
+		lp.publish(vehiclePresent, true)
+		lp.publish(vehicleTitle, lp.vehicle.Title())
+		lp.publish(vehicleIcon, lp.vehicle.Icon())
+		lp.publish(vehicleCapacity, lp.vehicle.Capacity())
+
+		lp.applyAction(vehicle.OnIdentified())
+		lp.addTask(lp.vehicleOdometer)
+
+		lp.progress.Reset()
+	} else {
+		lp.socEstimator = nil
+
+		lp.publish(vehiclePresent, false)
+		lp.publish(vehicleTitle, "")
+		lp.publish(vehicleIcon, "")
+		lp.publish(vehicleCapacity, int64(0))
+		lp.publish(vehicleOdometer, 0.0)
+	}
+
+	// re-publish vehicle settings
+	lp.publish(phasesActive, lp.activePhases())
+	lp.unpublishVehicle()
+
+	lp.updateSession(func(session *db.Session) {
+		var title string
+		if lp.vehicle != nil {
+			title = lp.vehicle.Title()
+		}
+
+		lp.session.Vehicle = title
+	})
+}
+
+func (lp *Loadpoint) wakeUpVehicle() {
+	// charger
+	if c, ok := lp.charger.(api.Resurrector); ok {
+		if err := c.WakeUp(); err != nil {
+			lp.log.ERROR.Printf("wake-up charger: %v", err)
+		}
+		return
+	}
+
+	// vehicle
+	if vs, ok := lp.vehicle.(api.Resurrector); ok {
+		if err := vs.WakeUp(); err != nil {
+			lp.log.ERROR.Printf("wake-up vehicle: %v", err)
+		}
+	}
+}
+
+// unpublishVehicle resets published vehicle data
+func (lp *Loadpoint) unpublishVehicle() {
+	lp.vehicleSoc = 0
+
+	lp.publish(vehicleSoc, 0.0)
+	lp.publish(vehicleRange, int64(0))
+	lp.publish(vehicleTargetSoc, 0.0)
+
+	lp.setRemainingEnergy(0)
+	lp.setRemainingDuration(0)
+
+	lp.vehiclePublishFeature(api.Offline)
+}
+
+// vehicleHasFeature checks availability of vehicle feature
+func (lp *Loadpoint) vehicleHasFeature(f api.Feature) bool {
+	v, ok := lp.vehicle.(api.FeatureDescriber)
+	if ok {
+		ok = v.Has(f)
+	}
+	return ok
+}
+
+// vehiclePublishFeature availability of vehicle features
+func (lp *Loadpoint) vehiclePublishFeature(f api.Feature) {
+	lp.publish("vehicleFeature"+f.String(), lp.vehicleHasFeature(f))
+}
+
+// vehicleUnidentified returns true if there are associated vehicles and detection is running.
+// It will also reset the api cache at regular intervals.
+// Detection is stopped after maximum duration and the "guest vehicle" message dispatched.
+func (lp *Loadpoint) vehicleUnidentified() bool {
+	if lp.vehicle != nil || lp.vehicleDetect.IsZero() || len(lp.coordinatedVehicles()) == 0 {
+		return false
+	}
+
+	// stop detection
+	if lp.clock.Since(lp.vehicleDetect) > vehicleDetectDuration {
+		lp.stopVehicleDetection()
+		lp.pushEvent(evVehicleUnidentified)
+		return false
+	}
+
+	// request vehicle api refresh while waiting to identify
+	select {
+	case <-lp.vehicleDetectTicker.C:
+		lp.log.DEBUG.Println("vehicle api refresh")
+		provider.ResetCached()
+	default:
+	}
+
+	return true
+}
+
+// vehicleDefaultOrDetect will assign and update default vehicle or start detection
+func (lp *Loadpoint) vehicleDefaultOrDetect() {
+	if lp.defaultVehicle != nil {
+		if lp.vehicle != lp.defaultVehicle {
+			lp.setActiveVehicle(lp.defaultVehicle)
+		} else {
+			// default vehicle is already active, update odometer anyway
+			// need to do this here since setActiveVehicle would short-circuit
+			lp.addTask(lp.vehicleOdometer)
+		}
+	} else if len(lp.coordinatedVehicles()) > 0 && lp.connected() {
+		lp.startVehicleDetection()
+	}
+}
+
+// startVehicleDetection reset connection timer and starts api refresh timer
+func (lp *Loadpoint) startVehicleDetection() {
+	// flush all vehicles before detection starts
+	lp.log.DEBUG.Println("vehicle api refresh")
+	provider.ResetCached()
+
+	lp.vehicleDetect = lp.clock.Now()
+	lp.vehicleDetectTicker = lp.clock.Ticker(vehicleDetectInterval)
+	lp.publish(vehicleDetectionActive, true)
+}
+
+// stopVehicleDetection expires the connection timer and ticker
+func (lp *Loadpoint) stopVehicleDetection() {
+	lp.vehicleDetect = time.Time{}
+	if lp.vehicleDetectTicker != nil {
+		lp.vehicleDetectTicker.Stop()
+	}
+	lp.publish(vehicleDetectionActive, false)
+}
+
+// identifyVehicleByStatus validates if the active vehicle is still connected to the loadpoint
+func (lp *Loadpoint) identifyVehicleByStatus() {
+	if len(lp.coordinatedVehicles()) == 0 {
+		return
+	}
+
+	if vehicle := lp.coordinator.IdentifyVehicleByStatus(); vehicle != nil {
+		lp.stopVehicleDetection()
+		lp.setActiveVehicle(vehicle)
+		return
+	}
+
+	// remove previous vehicle if status was not confirmed
+	if _, ok := lp.vehicle.(api.ChargeState); ok {
+		lp.setActiveVehicle(nil)
+	}
+}
+
+// vehicleOdometer updates odometer
+func (lp *Loadpoint) vehicleOdometer() {
+	if vs, ok := lp.vehicle.(api.VehicleOdometer); ok {
+		if odo, err := vs.Odometer(); err == nil {
+			lp.log.DEBUG.Printf("vehicle odometer: %.0fkm", odo)
+			lp.publish(vehicleOdometer, odo)
+
+			// update session once odometer is read
+			lp.updateSession(func(session *db.Session) {
+				session.Odometer = odo
+			})
+		} else {
+			lp.log.ERROR.Printf("vehicle odometer: %v", err)
+		}
+	}
+}
diff --git a/core/savings.go b/core/savings.go
index 93d4e08b..e10b49bb 100644
--- a/core/savings.go
+++ b/core/savings.go
@@ -1,11 +1,9 @@
 package core
 
 import (
-	"math"
 	"time"
 
 	"github.com/benbjohnson/clock"
-	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/server/db/settings"
 	"github.com/evcc-io/evcc/tariff"
 )
@@ -22,17 +20,16 @@ type publisher interface {
 
 // Site is the main configuration container. A site can host multiple loadpoints.
 type Savings struct {
-	clock                          clock.Clock
-	tariffs                        tariff.Tariffs
-	started                        time.Time // Boot time
-	updated                        time.Time // Time of last charged value update
-	gridCharged                    float64   // Grid energy charged since startup (kWh)
-	gridCost                       float64   // Running total of charged grid energy cost (e.g. EUR)
-	gridSavedCost                  float64   // Running total of saved cost from self consumption (e.g. EUR)
-	selfConsumptionCharged         float64   // Self-produced energy charged since startup (kWh)
-	selfConsumptionCost            float64   // Running total of charged self-produced energy cost (e.g. EUR)
-	lastGridPrice, lastFeedInPrice float64   // Stores the last published grid price. Needed to detect price changes (Awattar, ..)
-	hasPublished                   bool      // Has initial publish happened?
+	clock                  clock.Clock
+	tariffs                tariff.Tariffs
+	started                time.Time // Boot time
+	updated                time.Time // Time of last charged value update
+	gridCharged            float64   // Grid energy charged since startup (kWh)
+	gridCost               float64   // Running total of charged grid energy cost (e.g. EUR)
+	gridSavedCost          float64   // Running total of saved cost from self consumption (e.g. EUR)
+	selfConsumptionCharged float64   // Self-produced energy charged since startup (kWh)
+	selfConsumptionCost    float64   // Running total of charged self-produced energy cost (e.g. EUR)
+	hasPublished           bool      // Has initial publish happened?
 }
 
 func NewSavings(tariffs tariff.Tariffs) *Savings {
@@ -97,80 +94,45 @@ func (s *Savings) SavingsAmount() float64 {
 	return s.gridSavedCost
 }
 
-func (s *Savings) shareOfSelfProducedEnergy(gridPower, pvPower, batteryPower float64) float64 {
-	batteryDischarge := math.Max(0, batteryPower)
-	batteryCharge := math.Min(0, batteryPower) * -1
-	pvConsumption := math.Min(pvPower, pvPower+gridPower-batteryCharge)
-
-	gridImport := math.Max(0, gridPower)
-	selfConsumption := math.Max(0, batteryDischarge+pvConsumption+batteryCharge)
-
-	share := selfConsumption / (gridImport + selfConsumption)
-
-	if math.IsNaN(share) {
-		return 0
-	}
-
-	return share
-}
-
-func currentPrice(t api.Tariff, dfltPrice float64) float64 {
-	if t != nil {
-		if rr, err := t.Rates(); err == nil {
-			if r, err := rr.Current(time.Now()); err == nil {
-				return r.Price
-			}
-		}
-	}
-	return dfltPrice
-}
-
 func (s *Savings) currentGridPrice() float64 {
-	return currentPrice(s.tariffs.Grid, DefaultGridPrice)
+	price, err := s.tariffs.CurrentGridPrice()
+	if err != nil {
+		price = DefaultGridPrice
+	}
+	return price
 }
 
 func (s *Savings) currentFeedInPrice() float64 {
-	return currentPrice(s.tariffs.FeedIn, DefaultFeedInPrice)
-}
-
-func (s *Savings) updatePrices(p publisher) (float64, float64) {
-	gridPrice := s.currentGridPrice()
-	if gridPrice != s.lastGridPrice {
-		s.lastGridPrice = gridPrice
-		p.publish("tariffGrid", gridPrice)
-	}
-
-	feedinPrice := s.currentFeedInPrice()
-	if feedinPrice != s.lastFeedInPrice {
-		s.lastFeedInPrice = feedinPrice
-		p.publish("tariffFeedIn", feedinPrice)
+	price, err := s.tariffs.CurrentFeedInPrice()
+	if err != nil {
+		price = DefaultFeedInPrice
 	}
-
-	return gridPrice, feedinPrice
+	return price
 }
 
 // Update savings calculation and return grid/green energy added since last update
-func (s *Savings) Update(p publisher, gridPower, pvPower, batteryPower, chargePower float64) (float64, float64) {
-	gridPrice, feedinPrice := s.updatePrices(p)
+func (s *Savings) Update(p publisher, greenShare, chargePower float64) float64 {
 	defer func() { s.updated = s.clock.Now() }()
 
 	// no charging, no need to update
 	if chargePower == 0 && s.hasPublished {
-		return 0, 0
+		return 0
 	}
 
 	// assume charge power as constant over the duration -> rough kWh estimate
 	deltaCharged := s.clock.Since(s.updated).Hours() * chargePower / 1e3
-	share := s.shareOfSelfProducedEnergy(gridPower, pvPower, batteryPower)
 
-	deltaSelf := deltaCharged * share
+	deltaSelf := deltaCharged * greenShare
 	deltaGrid := deltaCharged - deltaSelf
 
+	gridPrice := s.currentGridPrice()
+	feedInPrice := s.currentFeedInPrice()
+
 	s.gridCharged += deltaGrid
 	s.gridCost += deltaGrid * gridPrice
-	s.gridSavedCost += deltaSelf * (gridPrice - feedinPrice)
+	s.gridSavedCost += deltaSelf * (gridPrice - feedInPrice)
 	s.selfConsumptionCharged += deltaSelf
-	s.selfConsumptionCost += deltaSelf * feedinPrice
+	s.selfConsumptionCost += deltaSelf * feedInPrice
 
 	p.publish("savingsTotalCharged", s.TotalCharged())
 	p.publish("savingsGridCharged", s.gridCharged)
@@ -178,9 +140,10 @@ func (s *Savings) Update(p publisher, gridPower, pvPower, batteryPower, chargePo
 	p.publish("savingsSelfConsumptionPercent", s.SelfConsumptionPercent())
 	p.publish("savingsEffectivePrice", s.EffectivePrice())
 	p.publish("savingsAmount", s.SavingsAmount())
+
 	s.hasPublished = true
 
 	s.save()
 
-	return deltaCharged, deltaSelf
+	return deltaCharged
 }
diff --git a/core/site.go b/core/site.go
index b99e00d7..ff4e3f0a 100644
--- a/core/site.go
+++ b/core/site.go
@@ -75,6 +75,8 @@ type Site struct {
 	batteryPower    float64 // Battery charge power
 	batterySoc      float64 // Battery soc
 	batteryBuffered bool    // Battery buffer active
+
+	publishCache map[string]any // store last published values to avoid unnecessary republishing
 }
 
 // MetersConfig contains the loadpoint's meter configuration
@@ -207,8 +209,9 @@ func NewSiteFromConfig(
 // NewSite creates a Site with sane defaults
 func NewSite() *Site {
 	lp := &Site{
-		log:     util.NewLogger("site"),
-		Voltage: 230, // V
+		log:          util.NewLogger("site"),
+		publishCache: make(map[string]any),
+		Voltage:      230, // V
 	}
 
 	return lp
@@ -331,6 +334,16 @@ func (site *Site) publish(key string, val interface{}) {
 	}
 }
 
+// publishDelta deduplicates messages before publishing
+func (site *Site) publishDelta(key string, val interface{}) {
+	if v, ok := site.publishCache[key]; ok && v == val {
+		return
+	}
+
+	site.publishCache[key] = val
+	site.publish(key, val)
+}
+
 // updateMeter updates and publishes single meter
 func (site *Site) updateMeter(meter api.Meter, power *float64) func() error {
 	return func() error {
@@ -544,6 +557,65 @@ func (site *Site) sitePower(totalChargePower float64) (float64, error) {
 	return sitePower, nil
 }
 
+func (site *Site) greenShare() float64 {
+	batteryDischarge := math.Max(0, site.batteryPower)
+	batteryCharge := -math.Min(0, site.batteryPower)
+	pvConsumption := math.Min(site.pvPower, site.pvPower+site.gridPower-batteryCharge)
+
+	gridImport := math.Max(0, site.gridPower)
+	selfConsumption := math.Max(0, batteryDischarge+pvConsumption+batteryCharge)
+
+	share := selfConsumption / (gridImport + selfConsumption)
+
+	if math.IsNaN(share) {
+		return 0
+	}
+
+	return share
+}
+
+// effectivePrice calculates the real energy price based on self-produced and grid-imported energy.
+func (s *Site) effectivePrice(greenShare float64) (float64, error) {
+	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
+	}
+	return 0, api.ErrNotAvailable
+}
+
+// effectiveCo2 calculates the amount of emitted co2 based on self-produced and grid-imported energy.
+func (s *Site) effectiveCo2(greenShare float64) (float64, error) {
+	if co2, err := s.tariffs.CurrentCo2(); err == nil {
+		return co2 * (1 - greenShare), nil
+	}
+	return 0, api.ErrNotAvailable
+}
+
+func (s *Site) publishTariffs() {
+	greenShare := s.greenShare()
+
+	s.publish("greenShare", greenShare)
+
+	if gridPrice, err := s.tariffs.CurrentGridPrice(); err == nil {
+		s.publishDelta("tariffGrid", gridPrice)
+	}
+	if feedInPrice, err := s.tariffs.CurrentFeedInPrice(); err == nil {
+		s.publishDelta("tariffFeedIn", feedInPrice)
+	}
+	if co2, err := s.tariffs.CurrentCo2(); err == nil {
+		s.publishDelta("tariffCo2", co2)
+	}
+	if price, err := s.effectivePrice(greenShare); err == nil {
+		s.publish("tariffEffectivePrice", price)
+	}
+	if co2, err := s.effectiveCo2(greenShare); err == nil {
+		s.publish("tariffEffectiveCo2", co2)
+	}
+}
+
 func (site *Site) update(lp Updater) {
 	site.log.DEBUG.Println("----")
 
@@ -565,11 +637,13 @@ func (site *Site) update(lp Updater) {
 		site.Health.Update()
 	}
 
-	// update savings and aggregate telemetry
+	site.publishTariffs()
+	greenShare := site.greenShare()
+
 	// TODO: use energy instead of current power for better results
-	deltaCharged, deltaSelf := site.savings.Update(site, site.gridPower, site.pvPower, site.batteryPower, totalChargePower)
+	deltaCharged := site.savings.Update(site, greenShare, totalChargePower)
 	if telemetry.Enabled() && totalChargePower > standbyPower {
-		go telemetry.UpdateChargeProgress(site.log, totalChargePower, deltaCharged, deltaSelf)
+		go telemetry.UpdateChargeProgress(site.log, totalChargePower, deltaCharged, greenShare)
 	}
 }
 
diff --git a/histoire.config.js b/histoire.config.js
index f60de21a..f6ff3a51 100644
--- a/histoire.config.js
+++ b/histoire.config.js
@@ -6,4 +6,16 @@ export default defineConfig({
   setupFile: "./histoire.setup.js",
   viteNodeInlineDeps: [/!axios/],
   routerMode: "hash",
+  backgroundPresets: [
+    {
+      label: "default",
+      color: "var(--evcc-background)",
+      contrastColor: "var(--evcc-default-text)",
+    },
+    {
+      label: "box",
+      color: "var(--evcc-box)",
+      contrastColor: "var(--evcc-default-text)",
+    },
+  ],
 });
diff --git a/i18n/de.toml b/i18n/de.toml
index 55623dd2..9cd0d6cf 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -72,6 +72,7 @@ vehicles = "Parkplatz"
 battery = "Batterie"
 batteryCharge = "Batterie laden"
 batteryDischarge = "Batterie entladen"
+batteryTooltip = "{energy} von {total} ({soc}%)"
 gridImport = "Netzbezug"
 homePower = "Verbrauch"
 loadpoints = "Ladepunkt | Ladepunkt | {count} Ladepunkte"
@@ -229,6 +230,12 @@ vehicle = "Fahrzeug"
 [settings]
 title = "Einstellungen"
 
+[settings.gridDetails]
+co2 = "CO₂"
+label = "Netztarifdetails"
+none = "keine"
+price = "Preis"
+
 [settings.language]
 auto = "Automatisch"
 label = "Sprache"
diff --git a/i18n/en.toml b/i18n/en.toml
index 761225dc..54d4099b 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -72,6 +72,7 @@ vehicles = "Parking"
 battery = "Battery"
 batteryCharge = "Battery charging"
 batteryDischarge = "Battery discharging"
+batteryTooltip = "{energy} of {total} ({soc}%)"
 gridImport = "Grid use"
 homePower = "Consumption"
 loadpoints = "Charger| Charger | {count} chargers"
@@ -229,6 +230,12 @@ vehicle = "Vehicle"
 [settings]
 title = "Settings"
 
+[settings.gridDetails]
+co2 = "CO₂"
+label = "Grid details"
+none = "none"
+price = "price"
+
 [settings.language]
 auto = "Automatic"
 label = "Language"
diff --git a/meter/powerwall.go b/meter/powerwall.go
index 7da3fc65..57b3b522 100644
--- a/meter/powerwall.go
+++ b/meter/powerwall.go
@@ -92,7 +92,7 @@ func NewPowerWall(uri, usage, user, password string) (api.Meter, error) {
 		}
 
 		batteryCapacity = func() float64 {
-			return float64(res.NominalFullPackEnergy)
+			return float64(res.NominalFullPackEnergy) / 1e3
 		}
 	}
 
@@ -125,9 +125,9 @@ func (m *PowerWall) totalEnergy() (float64, error) {
 	if o, ok := res[m.usage]; ok {
 		switch m.usage {
 		case "load":
-			return float64(o.EnergyImported), nil
+			return float64(o.EnergyImported) / 1e3, nil
 		case "solar":
-			return float64(o.EnergyExported), nil
+			return float64(o.EnergyExported) / 1e3, nil
 		}
 	}
 
diff --git a/tariff/electricitymaps.go b/tariff/electricitymaps.go
index d02f62f2..d71e8331 100644
--- a/tariff/electricitymaps.go
+++ b/tariff/electricitymaps.go
@@ -104,7 +104,7 @@ func (t *ElectricityMaps) run(done chan error) {
 
 // Unit implements the api.Tariff interface
 func (t *ElectricityMaps) Unit() string {
-	return "gCO2eq"
+	return Co2Equivalent
 }
 
 func (t *ElectricityMaps) Rates() (api.Rates, error) {
diff --git a/tariff/gruenstromindex.go b/tariff/gruenstromindex.go
index 556b5be6..7075cadf 100644
--- a/tariff/gruenstromindex.go
+++ b/tariff/gruenstromindex.go
@@ -126,7 +126,7 @@ func (t *GrünStromIndex) run(done chan error) {
 
 // Unit implements the api.Tariff interface
 func (t *GrünStromIndex) Unit() string {
-	return "gCO2eq"
+	return Co2Equivalent
 }
 
 // Rates implements the api.Tariff interface
diff --git a/tariff/tariffs.go b/tariff/tariffs.go
index 4a11c1c3..5b0c1e20 100644
--- a/tariff/tariffs.go
+++ b/tariff/tariffs.go
@@ -7,6 +7,9 @@ import (
 	"golang.org/x/text/currency"
 )
 
+// Co2Equivalent is the unit for co2 emissions
+const Co2Equivalent = "gCO2eq"
+
 type Tariffs struct {
 	Currency              currency.Unit
 	Grid, FeedIn, Planner api.Tariff
@@ -25,6 +28,35 @@ func NewTariffs(currency currency.Unit, grid, feedin, planner api.Tariff) *Tarif
 	}
 }
 
+func currentPrice(t api.Tariff) (float64, error) {
+	if t != nil {
+		if rr, err := t.Rates(); err == nil {
+			if r, err := rr.Current(time.Now()); err == nil {
+				return r.Price, nil
+			}
+		}
+	}
+	return 0, api.ErrNotAvailable
+}
+
+// CurrentGridPrice returns the current grid price.
+func (t *Tariffs) CurrentGridPrice() (float64, error) {
+	return currentPrice(t.Grid)
+}
+
+// CurrentFeedInPrice returns the current feed-in price.
+func (t *Tariffs) CurrentFeedInPrice() (float64, error) {
+	return currentPrice(t.FeedIn)
+}
+
+// CurrentCo2 determines the grids co2 emission.
+func (t *Tariffs) CurrentCo2() (float64, error) {
+	if t.Planner != nil && t.Planner.Unit() == Co2Equivalent {
+		return currentPrice(t.Planner)
+	}
+	return 0, api.ErrNotAvailable
+}
+
 // outdatedError returns api.ErrOutdated if t is older than 2*d
 func outdatedError(t time.Time, d time.Duration) error {
 	if time.Since(t) > 2*d {
diff --git a/templates/definition/charger/wattpilot.yaml b/templates/definition/charger/wattpilot.yaml
index b6550ddc..3215b9e3 100644
--- a/templates/definition/charger/wattpilot.yaml
+++ b/templates/definition/charger/wattpilot.yaml
@@ -3,7 +3,7 @@ products:
   - brand: Fronius
     description:
       generic: Wattpilot
-capabilities: ["rfid"]
+capabilities: ["1p3p", "rfid"]
 requirements:
   description:
     de: |
diff --git a/templates/docs/charger/fronius-wattpilot_0.yaml b/templates/docs/charger/fronius-wattpilot_0.yaml
index 004f1993..afba5cbf 100644
--- a/templates/docs/charger/fronius-wattpilot_0.yaml
+++ b/templates/docs/charger/fronius-wattpilot_0.yaml
@@ -1,7 +1,7 @@
 product:
   brand: Fronius
   description: Wattpilot
-capabilities: ["rfid"]
+capabilities: ["1p3p", "rfid"]
 description: |
   Benötigt mindestens Firmware 36.3 oder neuer.
 
diff --git a/util/tee.go b/util/tee.go
index 278ade00..eb8670f4 100644
--- a/util/tee.go
+++ b/util/tee.go
@@ -12,7 +12,9 @@ type Tee struct {
 
 // Attach creates a new receiver channel and attaches it to the tee
 func (t *Tee) Attach() <-chan Param {
-	out := make(chan Param, 1)
+	// TODO find better approach to prevent deadlocks
+	// this will buffer the receiver channel to prevent deadlocks when consumers use mutex-protected loadpoint api
+	out := make(chan Param, 16)
 	t.add(out)
 	return out
 }
diff --git a/util/telemetry/charge.go b/util/telemetry/charge.go
index 0d8ef32e..dde411d9 100644
--- a/util/telemetry/charge.go
+++ b/util/telemetry/charge.go
@@ -58,19 +58,19 @@ func Create(machineID string) {
 
 // UpdateChargeProgress accumulates the charge delta and uploads at given interval.
 // This interval must be smaller that the apis expiry interval for treating power values as current.
-func UpdateChargeProgress(log *util.Logger, power, deltaCharged, deltaGreen float64) {
+func UpdateChargeProgress(log *util.Logger, power, deltaCharged, greenShare float64) {
 	mu.Lock()
 	defer mu.Unlock()
 
 	// cache
 	accChargeEnergy += deltaCharged
-	accGreenEnergy += deltaGreen
+	accGreenEnergy += deltaCharged * greenShare
 
 	if time.Since(updated) < 30*time.Second {
 		return
 	}
 
-	if err := upload(log, power, power*deltaGreen/deltaCharged); err != nil {
+	if err := upload(log, power, power*greenShare); err != nil {
 		log.ERROR.Printf("telemetry: upload failed: %v", err)
 	}
 }


Test patch:
diff --git a/core/savings_test.go b/core/savings_test.go
index 118c5802..0be3e42e 100644
--- a/core/savings_test.go
+++ b/core/savings_test.go
@@ -39,55 +39,6 @@ type StubPublisher struct{}
 
 func (p StubPublisher) publish(key string, val interface{}) {}
 
-func TestSavingsWithChangingEnergySources(t *testing.T) {
-	p := StubPublisher{}
-
-	clck := clock.NewMock()
-	s := &Savings{
-		clock:   clck,
-		started: clck.Now(),
-		updated: clck.Now(),
-	}
-
-	tc := []struct {
-		title                     string
-		grid, pv, battery, charge float64
-		total, self, percentage   float64
-	}{
-		{"half grid, half pv",
-			2500, 2500, 0, 5000,
-			5, 2.5, 50},
-		{"full pv",
-			0, 5000, 0, 5000,
-			10, 7.5, 75},
-		{"full grid",
-			5000, 0, 0, 5000,
-			15, 7.5, 50},
-		{"half grid, half battery",
-			2500, 0, 2500, 5000,
-			20, 10, 50},
-		{"full pv, pv export",
-			-5000, 10000, 0, 5000,
-			25, 15, 60},
-		{"full pv, pv export, battery charge",
-			-2500, 10000, -2500, 5000,
-			30, 20, 66},
-		{"double charge speed, full grid",
-			10000, 0, 0, 10000,
-			40, 20, 50},
-	}
-
-	s.Update(p, 0, 0, 0, 0)
-
-	for _, tc := range tc {
-		t.Logf("%+v", tc)
-
-		clck.Add(time.Hour)
-		s.Update(p, tc.grid, tc.pv, tc.battery, tc.charge)
-		assertEnergy(t, s, tc.total, tc.self, tc.percentage)
-	}
-}
-
 func TestSavingsWithDifferentTimespans(t *testing.T) {
 	p := StubPublisher{}
 
@@ -99,8 +50,8 @@ func TestSavingsWithDifferentTimespans(t *testing.T) {
 	}
 
 	type tcStep = struct {
-		dt                        time.Duration
-		grid, pv, battery, charge float64
+		dt                 time.Duration
+		greenShare, charge float64
 	}
 
 	tc := []struct {
@@ -110,54 +61,54 @@ func TestSavingsWithDifferentTimespans(t *testing.T) {
 	}{
 		{"10 second not charging, full grid",
 			[]tcStep{
-				{10 * time.Second, 1000, 0, 0, 0},
+				{10 * time.Second, 0, 0},
 			},
 			0, 0, 0, // 0Wh
 		},
 		{"10 second 11kW charging, full grid",
 			[]tcStep{
-				{10 * time.Second, 0, 0, 0, 11000},
+				{10 * time.Second, 0, 11000},
 			},
 			0.030556, 0, 0, // 30,555Wh
 		},
 		{"10 second 11kW charging, full grid",
 			[]tcStep{
-				{10 * time.Second, 0, 0, 0, 11000},
+				{10 * time.Second, 0, 11000},
 			},
 			0.061111, 0, 0, // 61,111Wh
 		},
 		{"5x 2 second 11kW charging, full grid",
 			[]tcStep{
-				{2 * time.Second, 0, 0, 0, 11000},
-				{2 * time.Second, 0, 0, 0, 11000},
-				{2 * time.Second, 0, 0, 0, 11000},
-				{2 * time.Second, 0, 0, 0, 11000},
-				{2 * time.Second, 0, 0, 0, 11000},
+				{2 * time.Second, 0, 11000},
+				{2 * time.Second, 0, 11000},
+				{2 * time.Second, 0, 11000},
+				{2 * time.Second, 0, 11000},
+				{2 * time.Second, 0, 11000},
 			},
 			0.092, 0, 0, // 91,666Wh
 		},
 		{"30 min 11kW charging, full grid",
 			[]tcStep{
-				{30 * time.Minute, 0, 0, 0, 11000},
+				{30 * time.Minute, 0, 11000},
 			},
 			5.592, 0, 0, // 5561,111Wh
 		},
 		{"4 hours 11kW charging, full pv",
 			[]tcStep{
-				{4 * time.Hour, 0, 11000, 0, 11000},
+				{4 * time.Hour, 1, 11000},
 			},
 			49.592, 44, 88,
 		},
 	}
 
-	s.Update(p, 0, 0, 0, 0)
+	s.Update(p, 0, 0)
 
 	for _, tc := range tc {
 		t.Logf("%+v", tc)
 
 		for _, tc := range tc.steps {
 			clck.Add(tc.dt)
-			s.Update(p, tc.grid, tc.pv, tc.battery, tc.charge)
+			s.Update(p, tc.greenShare, tc.charge)
 		}
 
 		assertEnergy(t, s, tc.total, tc.self, tc.percentage)
@@ -170,8 +121,8 @@ func TestEffectiveEnergyPriceAndSavingsAmount(t *testing.T) {
 	clck := clock.NewMock()
 
 	type tcStep = struct {
-		dt                        time.Duration
-		grid, pv, battery, charge float64
+		dt                 time.Duration
+		greenShare, charge float64
 	}
 
 	tc := []struct {
@@ -181,26 +132,26 @@ func TestEffectiveEnergyPriceAndSavingsAmount(t *testing.T) {
 	}{
 		{"1 hour, 10kW, full grid",
 			[]tcStep{
-				{time.Hour, 10000, 0, 0, 10000},
+				{time.Hour, 0, 10000},
 			},
 			0.3, 0,
 		},
 		{"1 hour, 10kW, full pv",
 			[]tcStep{
-				{time.Hour, 0, 10000, 0, 10000},
+				{time.Hour, 1, 10000},
 			},
 			0.08, 2.2,
 		},
 		{"1 hour, 10kW, full battery",
 			[]tcStep{
-				{time.Hour, 0, 0, 10000, 10000},
+				{time.Hour, 1, 10000},
 			},
 			0.08, 2.2,
 		},
 
 		{"1 hour, 10kW, half grid, half pv",
 			[]tcStep{
-				{time.Hour, 5000, 0, 5000, 10000},
+				{time.Hour, 0.5, 10000},
 			},
 			0.19, 1.1,
 		},
@@ -214,11 +165,11 @@ func TestEffectiveEnergyPriceAndSavingsAmount(t *testing.T) {
 			started: clck.Now(),
 			updated: clck.Now(),
 		}
-		s.Update(p, 0, 0, 0, 0)
+		s.Update(p, 0, 0)
 
 		for _, tc := range tc.steps {
 			clck.Add(tc.dt)
-			s.Update(p, tc.grid, tc.pv, tc.battery, tc.charge)
+			s.Update(p, tc.greenShare, tc.charge)
 		}
 
 		assertPrices(t, s, tc.effectivePrice, tc.savingsAmount)
diff --git a/core/site_test.go b/core/site_test.go
index 51b7f896..148a058b 100644
--- a/core/site_test.go
+++ b/core/site_test.go
@@ -30,3 +30,48 @@ func TestSitePower(t *testing.T) {
 		}
 	}
 }
+
+func TestGreenShare(t *testing.T) {
+	tc := []struct {
+		title             string
+		grid, pv, battery float64
+		share             float64
+	}{
+		{"half grid, half pv",
+			2500, 2500, 0,
+			0.5},
+		{"full pv",
+			0, 5000, 0,
+			1},
+		{"full grid",
+			5000, 0, 0,
+			0},
+		{"half grid, half battery",
+			2500, 0, 2500,
+			0.5},
+		{"full pv, pv export",
+			-5000, 10000, 0,
+			1},
+		{"full pv, pv export, battery charge",
+			-2500, 10000, -2500,
+			1},
+		{"double charge speed, full grid",
+			10000, 0, 0,
+			0},
+	}
+
+	for _, tc := range tc {
+		t.Logf("%+v", tc)
+
+		s := &Site{
+			gridPower:    tc.grid,
+			pvPower:      tc.pv,
+			batteryPower: tc.battery,
+		}
+
+		share := s.greenShare()
+		if share != tc.share {
+			t.Errorf("greenShare wanted %.f, got %.f", tc.share, share)
+		}
+	}
+}