Refactor session settings and apis (BC) (#10335)

This commit is contained in:
andig 2023-12-02 20:56:30 +05:00 • committed by GitHub
parent 66143c2c61
commit d63dec9511
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
116 changed files with 4073 additions and 2539 deletions

View file

@ -5,32 +5,15 @@ import (
"reflect"
"strings"
"dario.cat/mergo"
"github.com/fatih/structs"
"github.com/jinzhu/copier"
)
// ActionConfig defines an action to take on event
type ActionConfig struct {
Mode *ChargeMode `mapstructure:"mode,omitempty"` // Charge Mode
MinCurrent *float64 `mapstructure:"minCurrent,omitempty"` // Minimum Current
MaxCurrent *float64 `mapstructure:"maxCurrent,omitempty"` // Maximum Current
MinSoc_ *int `mapstructure:"minSoc,omitempty"` // Minimum Soc (deprecated)
TargetSoc *int `mapstructure:"targetSoc,omitempty"` // Target Soc
Priority *int `mapstructure:"priority,omitempty"` // Priority
}
// Merge merges all non-nil properties of the additional config into the base config.
// The receiver's config remains immutable.
func (a ActionConfig) Merge(m ActionConfig) ActionConfig {
var res ActionConfig
if err := copier.Copy(&res, a); err != nil {
panic(err)
}
if err := mergo.MergeWithOverwrite(&res, m); err != nil {
panic(err)
}
return res
Mode ChargeMode `mapstructure:"mode,omitempty"` // Charge Mode
Priority int `mapstructure:"priority,omitempty"` // Priority
MinCurrent float64 `mapstructure:"minCurrent,omitempty"` // Minimum Current
MaxCurrent float64 `mapstructure:"maxCurrent,omitempty"` // Maximum Current
}
// String implements Stringer and returns the ActionConfig as comma-separated key:value string
@ -44,3 +27,19 @@ func (a ActionConfig) String() string {
}
return strings.Join(s, ", ")
}
func (a ActionConfig) GetMode() (ChargeMode, bool) {
return a.Mode, a.Mode != ""
}
func (a ActionConfig) GetMinCurrent() (float64, bool) {
return a.MinCurrent, a.MinCurrent > 0
}
func (a ActionConfig) GetMaxCurrent() (float64, bool) {
return a.MaxCurrent, a.MaxCurrent > 0
}
func (a ActionConfig) GetPriority() (int, bool) {
return a.Priority, a.Priority > 0
}

View file

@ -1,41 +0,0 @@
package api
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestMerge(t *testing.T) {
pv := ModePV
one := 1
six := 6.0
old := ActionConfig{
Mode: &pv,
Priority: &one,
MinCurrent: &six,
}
now := ModeNow
two := 2
new := ActionConfig{
Mode: &now,
Priority: &two,
}
dst := old.Merge(new)
// unmodified
assert.Equal(t, old, ActionConfig{
Mode: &pv,
Priority: &one,
MinCurrent: &six,
}, "old modified")
// overwritten
assert.Equal(t, dst, ActionConfig{
Mode: &now,
MinCurrent: &six,
Priority: &two,
}, "new wrong")
}

View file

@ -124,8 +124,8 @@ type ChargeState interface {
Status() (ChargeStatus, error)
}
// CurrentLimiter provides settings charging maximum charging current
type CurrentLimiter interface {
// CurrentController provides settings charging maximum charging current
type CurrentController interface {
MaxCurrent(current int64) error
}
@ -144,7 +144,7 @@ type Charger interface {
ChargeState
Enabled() (bool, error)
Enable(enable bool) error
CurrentLimiter
CurrentController
}
// ChargerEx provides milli-amp precision charger current control
@ -220,8 +220,14 @@ type VehiclePosition interface {
Position() (float64, float64, error)
}
// SocLimiter returns the vehicles charge limit
// CurrentLimiter returns the current limits
type CurrentLimiter interface {
GetMinMaxCurrent() (float64, float64, error)
}
// SocLimiter returns the soc limit
type SocLimiter interface {
// TODO rename LimitSoc
TargetSoc() (float64, error)
}

View file

@ -369,6 +369,10 @@ small {
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path fill='none' stroke='%23ffffff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/></svg>");
}
.dark .form-select:disabled {
opacity: 0.5;
}
.dark .table {
--bs-table-color: var(--evcc-default-text);
--bs-table-border-color: var(--evcc-gray);

View file

@ -6,17 +6,26 @@
:class="disabled ? 'opacity-25' : 'opacity-100'"
data-testid="charging-plan"
>
<h3 class="value m-0 d-block align-items-baseline justify-content-center">
<div class="value m-0 d-block align-items-baseline justify-content-center">
<button
class="value-button p-0"
:class="enabled ? 'evcc-default-text' : 'text-gray'"
@click="openModal"
>
<strong v-if="minSocEnabled">{{ minSocLabel }}</strong>
<strong v-else-if="targetChargeEnabled">{{ targetTimeLabel() }}</strong>
<span v-else>{{ $t("main.chargingPlan.none") }}</span>
<strong v-if="minSocEnabled" class="text-decoration-underline">
{{ minSocLabel }}
</strong>
<strong v-else-if="targetChargeEnabled">
<span class="text-decoration-underline"> {{ targetTimeLabel() }}</span>
<div class="extraValue text-nowrap">
{{ targetSocLabel }}
</div>
</strong>
<span v-else class="text-decoration-underline">
{{ $t("main.chargingPlan.none") }}
</span>
</button>
</h3>
</div>
</LabelAndValue>
<Teleport to="body">
@ -33,7 +42,8 @@
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ $t("main.chargingPlan.modalTitle") }}
{{ $t("main.chargingPlan.modalTitle")
}}<span v-if="socBasedPlanning">: {{ vehicle.title }}</span>
</h5>
<button
type="button"
@ -66,16 +76,17 @@
</li>
</ul>
<div v-if="isModalVisible">
<TargetCharge
<ChargingPlanSettings
v-if="departureTabActive"
v-bind="targetCharge"
@target-time-updated="setTargetTime"
@target-time-removed="removeTargetTime"
v-bind="chargingPlanSettingsProps"
@plan-updated="updatePlan"
@plan-removed="removePlan"
/>
<ChargingPlanArrival
v-if="arrivalTabActive"
v-bind="chargingPlanArrival"
@minsoc-updated="setMinSoc"
@limitsoc-updated="setLimitSoc"
/>
</div>
</div>
@ -89,34 +100,39 @@
<script>
import Modal from "bootstrap/js/dist/modal";
import LabelAndValue from "./LabelAndValue.vue";
import TargetCharge from "./TargetCharge.vue";
import ChargingPlanSettings from "./ChargingPlanSettings.vue";
import ChargingPlanArrival from "./ChargingPlanArrival.vue";
import formatter from "../mixins/formatter";
import collector from "../mixins/collector";
import api from "../api";
import { optionStep, fmtEnergy } from "../utils/energyOptions";
export default {
name: "ChargingPlan",
components: { LabelAndValue, TargetCharge, ChargingPlanArrival },
components: { LabelAndValue, ChargingPlanSettings, ChargingPlanArrival },
mixins: [formatter, collector],
props: {
id: [String, Number],
planActive: Boolean,
targetTime: String,
targetSoc: Number,
targetEnergy: Number,
socBasedCharging: Boolean,
currency: String,
disabled: Boolean,
minSoc: Number,
vehicleSoc: Number,
vehicleName: String,
effectiveLimitSoc: Number,
effectivePlanSoc: Number,
effectivePlanTime: String,
id: [String, Number],
limitEnergy: Number,
mode: String,
planActive: Boolean,
planEnergy: Number,
planTime: String,
rangePerSoc: Number,
smartCostLimit: Number,
smartCostType: String,
currency: String,
mode: String,
socBasedPlanning: Boolean,
socPerKwh: Number,
vehicle: Object,
vehicleCapacity: Number,
vehicleSoc: Number,
},
emits: ["target-time-updated", "target-time-removed", "minsoc-updated"],
data: function () {
return {
modal: null,
@ -125,8 +141,23 @@ export default {
};
},
computed: {
minSoc: function () {
return this.vehicle?.minSoc;
},
limitSoc: function () {
return this.vehicle?.limitSoc;
},
plans: function () {
if (this.socBasedPlanning) {
return this.vehicle?.plans || [];
}
if (this.planEnergy && this.planTime) {
return [{ energy: this.planEnergy, time: this.planTime }];
}
return [];
},
targetChargeEnabled: function () {
return this.targetTime;
return this.effectivePlanTime;
},
enabled: function () {
return this.targetChargeEnabled || this.minSocEnabled;
@ -141,9 +172,6 @@ export default {
if (this.minSocEnabled) {
return this.$t("main.chargingPlan.titleMinSoc");
}
if (this.targetChargeEnabled) {
return this.$t("main.chargingPlan.titleTargetCharge");
}
return this.$t("main.chargingPlan.title");
},
minSocEnabled: function () {
@ -155,12 +183,29 @@ export default {
arrivalTabActive: function () {
return this.activeTab === "arrival";
},
targetCharge: function () {
return this.collectProps(TargetCharge);
chargingPlanSettingsProps: function () {
return this.collectProps(ChargingPlanSettings);
},
chargingPlanArrival: function () {
return this.collectProps(ChargingPlanArrival);
},
targetSocLabel: function () {
if (this.socBasedPlanning) {
return `${Math.round(this.effectivePlanSoc)}%`;
}
return fmtEnergy(
this.planEnergy,
optionStep(this.vehicleCapacity || 100),
this.fmtKWh,
this.$t("main.targetEnergy.noLimit")
);
},
apiVehicle: function () {
return `vehicles/${this.vehicle.name}/`;
},
apiLoadpoint: function () {
return `loadpoints/${this.id}/`;
},
},
mounted() {
this.modal = Modal.getOrCreateInstance(this.$refs.modal);
@ -185,12 +230,14 @@ export default {
}
this.modal.show();
},
openPlanModal() {
this.showDeatureTab();
this.modal.show();
},
// not computed because it needs to update over time
targetTimeLabel: function () {
const targetDate = new Date(this.targetTime);
return this.$t("main.chargingPlan.activeLabel", {
time: this.fmtAbsoluteDate(targetDate),
});
const targetDate = new Date(this.effectivePlanTime);
return this.fmtAbsoluteDate(targetDate);
},
showDeatureTab: function () {
this.activeTab = "departure";
@ -198,16 +245,26 @@ export default {
showArrivalTab: function () {
this.activeTab = "arrival";
},
setTargetTime: function (targetTime) {
this.$emit("target-time-updated", targetTime);
this.modal.hide();
updatePlan: function ({ soc, time, energy }) {
const timeISO = time.toISOString();
if (this.socBasedPlanning) {
api.post(`${this.apiVehicle}plan/soc/${soc}/${timeISO}`);
} else {
api.post(`${this.apiLoadpoint}plan/energy/${energy}/${timeISO}`);
}
},
removeTargetTime: function () {
this.$emit("target-time-removed");
this.modal.hide();
removePlan: function () {
if (this.socBasedPlanning) {
api.delete(`${this.apiVehicle}plan/soc`);
} else {
api.delete(`${this.apiLoadpoint}plan/energy`);
}
},
setMinSoc: function (minSoc) {
this.$emit("minsoc-updated", minSoc);
setMinSoc: function (soc) {
api.post(`${this.apiVehicle}minsoc/${soc}`);
},
setLimitSoc: function (soc) {
api.post(`${this.apiVehicle}limitsoc/${soc}`);
},
},
};
@ -222,7 +279,6 @@ export default {
font-size: 18px;
border: none;
background: none;
text-decoration: underline;
}
.root {
transition: opacity var(--evcc-transition-medium) linear;
@ -230,4 +286,9 @@ export default {
.value:hover {
color: var(--bs-color-white);
}
.extraValue {
color: var(--evcc-gray);
font-size: 14px;
text-decoration: none;
}
</style>

View file

@ -1,77 +1,111 @@
<template>
<div class="mt-4 container">
<div class="row">
<div class="col-12 col-md-6">
<div
class="minsoc d-flex justify-content-between align-items-baseline mb-2 me-sm-4"
>
<label :for="formId('minsoc')" class="pt-1">
{{ $t("main.loadpointSettings.minSoc.label") }}
</label>
<select
:id="formId('minsoc')"
v-model.number="selectedMinSoc"
class="minSocSelect form-select mb-2"
:disabled="!socBasedCharging"
@change="changeMinSoc"
>
<option v-for="soc in minSocOptions" :key="soc.value" :value="soc.value">
{{ soc.name }}
</option>
</select>
</div>
<div class="col-6 col-lg-3 col-form-label">
<label :for="formId('minsoc')">
{{ $t("main.loadpointSettings.minSoc.label") }}
</label>
</div>
<small class="col-12 col-md-6 ps-md-4">
<div class="col-6 col-lg-3">
<select
:id="formId('minsoc')"
v-model.number="selectedMinSoc"
class="form-select mb-2"
:disabled="!socBasedPlanning"
@change="changeMinSoc"
>
<option v-for="soc in minSocOptions" :key="soc.value" :value="soc.value">
{{ soc.name }}
</option>
</select>
</div>
<small class="col-12 col-lg-6 ps-lg-4 col-form-label mb-4">
{{ $t("main.loadpointSettings.minSoc.description", [selectedMinSoc || "x"]) }}
</small>
</div>
<div class="row">
<div class="col-6 col-lg-3 col-form-label">
<label :for="formId('limitsoc')">
{{ $t("main.loadpointSettings.limitSoc.label") }}
</label>
</div>
<div class="col-6 col-lg-3">
<select
:id="formId('limitsoc')"
v-model.number="selectedLimitSoc"
class="form-select mb-2"
:disabled="!socBasedPlanning"
@change="changeLimitSoc"
>
<option v-for="soc in limitSocOptions" :key="soc.value" :value="soc.value">
{{ soc.name }}
</option>
</select>
</div>
<small class="col-12 col-lg-6 ps-lg-4 col-form-label mb-4">
{{ $t("main.loadpointSettings.limitSoc.description") }}
</small>
</div>
</div>
</template>
<script>
import { distanceUnit } from "../units";
import formatter from "../mixins/formatter";
export default {
name: "ChargingPlanArrival",
mixins: [formatter],
props: {
id: [String, Number],
minSoc: Number,
minSoc: { type: Number, default: 0 },
limitSoc: { type: Number, default: 0 },
vehicleName: String,
socBasedCharging: Boolean,
socBasedPlanning: Boolean,
rangePerSoc: Number,
},
emits: ["minsoc-updated"],
emits: ["minsoc-updated", "limitsoc-updated"],
data: function () {
return { selectedMinSoc: this.minSoc };
return { selectedMinSoc: this.minSoc, selectedLimitSoc: this.limitSoc };
},
computed: {
minSocOptions() {
// a list of entries from 0 to 95 with a step of 5
return Array.from(Array(20).keys())
.map((i) => i * 5)
.map((soc) => {
return { value: soc, name: soc === 0 ? "--" : `${soc}%` };
});
.map(this.socOption);
},
limitSocOptions() {
// a list of entries from 0 to 100 with a step of 5
return Array.from(Array(21).keys())
.map((i) => i * 5)
.map(this.socOption);
},
},
watch: {
minSoc: function (value) {
this.selectedMinSoc = value;
},
limitSoc: function (value) {
this.selectedLimitSoc = value;
},
},
methods: {
socOption: function (soc) {
return {
value: soc,
name: soc === 0 ? "---" : this.fmtSocOption(soc, this.rangePerSoc, distanceUnit()),
};
},
formId: function (name) {
return `chargingplan_${this.id}_${name}`;
},
changeMinSoc: function () {
this.$emit("minsoc-updated", this.selectedMinSoc);
},
changeLimitSoc: function () {
this.$emit("limitsoc-updated", this.selectedLimitSoc);
},
},
};
</script>
<style scoped>
.minsoc {
max-width: 300px;
}
.minSocSelect {
width: 100px;
}
</style>

View file

@ -1,5 +1,5 @@
<script setup>
import TargetChargePlan from "./TargetChargePlan.vue";
import ChargingPlanPreview from "./ChargingPlanPreview.vue";
const now = new Date();
@ -80,21 +80,21 @@ const dynamic = {
</script>
<template>
<Story title="TargetChargePlan">
<Story title="ChargingPlanPreview">
<Variant title="co2">
<TargetChargePlan v-bind="co2" />
<ChargingPlanPreview v-bind="co2" />
</Variant>
<Variant title="fixed">
<TargetChargePlan v-bind="fixed" />
<ChargingPlanPreview v-bind="fixed" />
</Variant>
<Variant title="zoned">
<TargetChargePlan v-bind="zoned" />
<ChargingPlanPreview v-bind="zoned" />
</Variant>
<Variant title="unknown">
<TargetChargePlan v-bind="unknown" />
<ChargingPlanPreview v-bind="unknown" />
</Variant>
<Variant title="dynamic">
<TargetChargePlan v-bind="dynamic" />
<ChargingPlanPreview v-bind="dynamic" />
</Variant>
</Story>
</template>

View file

@ -1,6 +1,6 @@
import { mount, config } from "@vue/test-utils";
import { beforeAll, describe, expect, test } from "vitest";
import TargetChargePlan from "./TargetChargePlan.vue";
import ChargingPlanPreview from "./ChargingPlanPreview.vue";
config.global.mocks["$i18n"] = { locale: "de-DE" };
config.global.mocks["$t"] = (a) => a;
@ -22,7 +22,7 @@ describe("basics", () => {
},
];
const wrapper = mount(TargetChargePlan, {
const wrapper = mount(ChargingPlanPreview, {
props: {
plan: PLAN,
targetTime: DATE_TARGET,
@ -106,7 +106,7 @@ describe("zoned tariffs", () => {
let result = null;
const wrapper = mount(TargetChargePlan, {
const wrapper = mount(ChargingPlanPreview, {
props: {
plan: PLAN,
targetTime: DATE_TARGET,

View file

@ -1,5 +1,5 @@
<template>
<div class="plan my-3">
<div class="plan">
<div class="justify-content-between mb-2 d-flex justify-content-between">
<div class="text-start">
<div class="label">{{ $t("main.targetChargePlan.chargeDuration") }}</div>
@ -26,11 +26,12 @@ import { CO2_TYPE } from "../units";
import TariffChart from "./TariffChart.vue";
export default {
name: "TargetChargePlan",
name: "ChargingPlanPreview",
components: { TariffChart },
mixins: [formatter],
props: {
duration: Number,
power: Number,
rates: Array,
plan: Array,
smartCostType: String,

View file

@ -0,0 +1,175 @@
<template>
<div class="mt-4">
<div class="form-group d-lg-flex align-items-baseline justify-content-between">
<div v-if="plans.length > 0" class="container px-0 mb-3">
<ChargingPlanSettingsEntry
v-for="(p, index) in plans"
:id="`${id}_${index}`"
:key="index"
class="mb-2"
v-bind="p"
:vehicle-capacity="vehicleCapacity"
:range-per-soc="rangePerSoc"
:soc-per-kwh="socPerKwh"
:soc-based-planning="socBasedPlanning"
@plan-updated="(data) => updatePlan({ index, ...data })"
@plan-removed="() => removePlan(index)"
/>
</div>
<div v-else>
<p>
{{ $t("main.targetCharge.planDescription") }}
</p>
<button class="btn btn-outline-primary" type="button" @click="addPlan">
{{ $t("main.targetCharge.setPlan") }}
</button>
</div>
</div>
<div v-if="plans.length > 0">
<ChargingPlanWarnings v-bind="chargingPlanWarningsProps" class="mb-4" />
<hr />
<h5>PREVIEW</h5>
<ChargingPlanPreview
v-if="chargingPlanPreviewProps"
v-bind="chargingPlanPreviewProps"
/>
</div>
</div>
</template>
<script>
import ChargingPlanPreview from "./ChargingPlanPreview.vue";
import ChargingPlanSettingsEntry from "./ChargingPlanSettingsEntry.vue";
import ChargingPlanWarnings from "./ChargingPlanWarnings.vue";
import formatter from "../mixins/formatter";
import collector from "../mixins/collector";
import api from "../api";
const DEFAULT_TARGET_TIME = "7:00";
const LAST_TARGET_TIME_KEY = "last_target_time";
export default {
name: "ChargingPlanSettings",
components: { ChargingPlanPreview, ChargingPlanSettingsEntry, ChargingPlanWarnings },
mixins: [formatter, collector],
props: {
id: [String, Number],
plans: { type: Array, default: () => [] },
effectiveLimitSoc: Number,
effectivePlanTime: String,
effectivePlanSoc: Number,
planEnergy: Number,
limitEnergy: Number,
socBasedPlanning: Boolean,
socPerKwh: Number,
rangePerSoc: Number,
smartCostLimit: Number,
smartCostType: String,
currency: String,
mode: String,
vehicleCapacity: Number,
vehicle: Object,
},
emits: ["plan-removed", "plan-updated"],
data: function () {
return {
tariff: {},
plan: {},
activeTab: "time",
loading: false,
};
},
computed: {
chargingPlanWarningsProps: function () {
return this.collectProps(ChargingPlanWarnings);
},
selectedTargetTime: function () {
return new Date(this.effectivePlanTime);
},
chargingPlanPreviewProps: function () {
const targetTime = this.selectedTargetTime;
const { rates } = this.tariff;
const { duration, plan, power } = this.plan;
const { currency, smartCostType } = this;
return rates
? { duration, plan, power, rates, targetTime, currency, smartCostType }
: null;
},
},
watch: {
plans: {
handler() {
this.fetchPlan();
},
deep: true,
},
},
mounted() {
this.fetchPlan();
},
methods: {
fetchPlan: async function () {
if (this.plans.length > 0 && !this.loading) {
try {
this.loading = true;
this.plan = (await api.get(`loadpoints/${this.id}/plan`)).data.result;
const tariffRes = await api.get(`tariff/planner`, {
validateStatus: function (status) {
return status >= 200 && status < 500;
},
});
this.tariff = tariffRes.status === 404 ? { rates: [] } : tariffRes.data.result;
} catch (e) {
console.error(e);
} finally {
this.loading = false;
}
}
},
defaultDate: function () {
const [hours, minutes] = (
window.localStorage[LAST_TARGET_TIME_KEY] || DEFAULT_TARGET_TIME
).split(":");
const target = new Date();
target.setSeconds(0);
target.setMinutes(minutes);
target.setHours(hours);
// today or tomorrow?
const isInPast = target < new Date();
if (isInPast) {
target.setDate(target.getDate() + 1);
}
return target;
},
addPlan: function () {
this.$emit("plan-updated", {
time: this.defaultDate(),
soc: 100,
energy: this.vehicleCapacity || 10,
});
},
removePlan: function (index) {
this.$emit("plan-removed", index);
},
updatePlan: function (data) {
this.$emit("plan-updated", data);
},
},
};
</script>
<style scoped>
h5 {
position: relative;
display: inline-block;
background-color: white;
top: -25px;
left: calc(50% - 50px);
padding: 0 0.5rem;
font-weight: normal;
color: var(--bs-gray);
margin-bottom: -4rem;
}
</style>

View file

@ -0,0 +1,230 @@
<template>
<div>
<div class="row d-none d-lg-flex mb-2">
<div class="col-6 col-lg-4">
<label :for="formId('day')">
{{ $t("main.chargingPlan.day") }}
</label>
</div>
<div class="col-6 col-lg-3">
<label :for="formId('time')">
{{ $t("main.chargingPlan.time") }}
</label>
</div>
<div class="col-6 col-lg-3">
<label :for="formId('goal')">
{{ $t("main.chargingPlan.goal") }}
</label>
</div>
<div class="col-2"></div>
</div>
<div class="row">
<div class="col-6 d-lg-none col-form-label">
<label :for="formId('day')">
{{ $t("main.chargingPlan.day") }}
</label>
</div>
<div class="col-6 col-lg-4 mb-2 mb-lg-0">
<select
:id="formId('day')"
v-model="selectedDay"
class="form-select me-2"
data-testid="plan-day"
>
<option v-for="opt in dayOptions()" :key="opt.value" :value="opt.value">
{{ opt.name }}
</option>
</select>
</div>
<div class="col-6 d-lg-none col-form-label">
<label :for="formId('day')">
{{ $t("main.chargingPlan.time") }}
</label>
</div>
<div class="col-6 col-lg-3 mb-2 mb-lg-0">
<input
:id="formId('time')"
v-model="selectedTime"
type="time"
class="form-control mx-0"
:step="60 * 5"
data-testid="plan-time"
required
/>
</div>
<div class="col-6 d-lg-none col-form-label">
<label :for="formId('goal')">
{{ $t("main.chargingPlan.goal") }}
</label>
</div>
<div class="col-6 col-lg-3 mb-2 mb-lg-0">
<select
v-if="socBasedPlanning"
:id="formId('goal')"
v-model="selectedSoc"
class="form-select mx-0"
data-testid="plan-soc"
>
<option v-for="opt in socOptions" :key="opt.value" :value="opt.value">
{{ opt.name }}
</option>
</select>
<select
v-else
:id="formId('goal')"
v-model="selectedEnergy"
class="form-select mx-0"
data-testid="plan-energy"
>
<option v-for="opt in energyOptions" :key="opt.energy" :value="opt.energy">
{{ opt.text }}
</option>
</select>
</div>
<div class="col-12 col-lg-2 d-flex justify-content-end align-items-baseline">
<button
type="button"
class="btn text-muted text-decoration-underline"
@click="removePlan"
>
{{ $t("main.chargingPlan.remove") }}
</button>
</div>
</div>
</div>
</template>
<script>
import { distanceUnit } from "../units";
import formatter from "../mixins/formatter";
import { energyOptions } from "../utils/energyOptions";
const LAST_TARGET_TIME_KEY = "last_target_time";
export default {
name: "ChargingPlanSettingsEntry",
mixins: [formatter],
props: {
id: String,
soc: Number,
energy: Number,
time: String,
rangePerSoc: Number,
socPerKwh: Number,
vehicleCapacity: Number,
socBasedPlanning: Boolean,
},
emits: ["plan-updated", "plan-removed"],
data: function () {
return {
selectedDay: null,
selectedTime: null,
selectedSoc: this.soc,
selectedEnergy: this.energy,
};
},
computed: {
timeInThePast: function () {
const now = new Date();
return now >= this.selectedDate;
},
selectedDate: function () {
return new Date(`${this.selectedDay}T${this.selectedTime || "00:00"}`);
},
socOptions: function () {
// a list of entries from 5 to 100 with a step of 5
return Array.from(Array(20).keys())
.map((i) => 5 + i * 5)
.map(this.socOption);
},
energyOptions: function () {
const options = energyOptions(
0,
this.vehicleCapacity || 100,
this.socPerKwh,
this.fmtKWh,
"-"
);
// remove the first entry (0)
return options.slice(1);
},
},
watch: {
time() {
this.initInputFields();
},
selectedDate() {
this.updatePlan();
},
selectedSoc() {
this.updatePlan();
},
selectedEnergy() {
this.updatePlan();
},
soc() {
this.selectedSoc = this.soc;
},
energy() {
this.selectedEnergy = this.energy;
},
},
mounted() {
this.initInputFields();
},
methods: {
formId: function (name) {
return `chargingplan-${this.id}-${name}`;
},
socOption: function (value) {
const name = this.fmtSocOption(value, this.rangePerSoc, distanceUnit());
return { value, name };
},
initInputFields: function () {
const date = new Date(this.time);
this.selectedDay = this.fmtDayString(date);
this.selectedTime = this.fmtTimeString(date);
},
dayOptions: function () {
const options = [];
const date = new Date();
const labels = [
this.$t("main.targetCharge.today"),
this.$t("main.targetCharge.tomorrow"),
];
for (let i = 0; i < 7; i++) {
const dayNumber = date.toLocaleDateString(this.$i18n.locale, {
month: "short",
day: "numeric",
});
const dayName =
labels[i] || date.toLocaleDateString(this.$i18n.locale, { weekday: "long" });
options.push({
value: this.fmtDayString(date),
name: `${dayNumber} (${dayName})`,
});
date.setDate(date.getDate() + 1);
}
return options;
},
updatePlan: function () {
try {
const hours = this.selectedDate.getHours();
const minutes = this.selectedDate.getMinutes();
window.localStorage[LAST_TARGET_TIME_KEY] = `${hours}:${minutes}`;
} catch (e) {
console.warn(e);
}
this.$emit("plan-updated", {
time: this.selectedDate,
soc: this.selectedSoc,
energy: this.selectedEnergy,
});
},
removePlan: function () {
this.$emit("plan-removed");
},
},
};
</script>

View file

@ -0,0 +1,110 @@
<template>
<p class="mb-0">
<span v-if="['off', 'now'].includes(mode)" class="d-block text-secondary mb-1">
{{ $t("main.targetCharge.onlyInPvMode") }}
</span>
<span v-if="targetIsAboveLimit" class="d-block text-danger mb-1">
{{
$t("main.targetCharge.targetIsAboveLimit", {
limit: limitFmt,
goal: goalFmt,
})
}}
</span>
<span v-if="timeInThePast" class="d-block text-danger mb-1">
{{ $t("main.targetCharge.targetIsInThePast") }}
</span>
<span v-if="timeTooFarInTheFuture" class="d-block text-secondary mb-1">
{{ $t("main.targetCharge.targetIsTooFarInTheFuture") }}
</span>
<span v-if="costLimitExists" class="d-block text-secondary mb-1">
{{
$t("main.targetCharge.costLimitIgnore", {
limit: costLimitText,
})
}}
</span>
</p>
</template>
<script>
import { CO2_TYPE } from "../units";
import formatter from "../mixins/formatter";
export default {
name: "ChargingPlanWarnings",
mixins: [formatter],
props: {
id: [String, Number],
effectiveLimitSoc: Number,
effectivePlanTime: String,
effectivePlanSoc: Number,
planEnergy: Number,
limitEnergy: Number,
socBasedPlanning: Boolean,
socPerKwh: Number,
rangePerSoc: Number,
smartCostLimit: Number,
smartCostType: String,
currency: String,
mode: String,
tariff: Object,
selectedTargetTime: Date,
},
computed: {
timeInThePast: function () {
const now = new Date();
return now >= this.selectedTargetTime;
},
timeTooFarInTheFuture: function () {
if (this.tariff?.rates) {
const lastRate = this.tariff.rates[this.tariff.rates.length - 1];
if (lastRate?.end) {
const end = new Date(lastRate.end);
return this.selectedTargetTime >= end;
}
}
return false;
},
targetIsAboveLimit: function () {
if (this.socBasedPlanning) {
return this.effectivePlanSoc > this.effectiveLimitSoc;
}
return this.limitEnergy && this.planEnergy > this.limitEnergy;
},
limitFmt: function () {
if (this.socBasedPlanning) {
return this.fmtSoc(this.effectiveLimitSoc);
}
return this.fmtKWh(this.limitEnergy * 1e3);
},
goalFmt: function () {
if (this.socBasedPlanning) {
return this.fmtSoc(this.effectivePlanSoc);
}
return this.fmtKWh(this.planEnergy * 1e3);
},
costLimitExists: function () {
return this.smartCostLimit !== 0;
},
costLimitText: function () {
if (this.isCo2) {
return this.$t("main.targetCharge.co2Limit", {
co2: this.fmtCo2Short(this.smartCostLimit),
});
}
return this.$t("main.targetCharge.priceLimit", {
price: this.fmtPricePerKWh(this.smartCostLimit, this.currency, true),
});
},
isCo2() {
return this.smartCostType === CO2_TYPE;
},
},
methods: {
fmtSoc(soc) {
return `${Math.round(soc)}%`;
},
},
};
</script>

View file

@ -189,7 +189,7 @@ export default {
props: {
session: Object,
currency: String,
vehicles: [Object],
vehicles: Array,
},
emits: ["session-changed"],
computed: {
@ -218,9 +218,9 @@ export default {
formatKm: function (value) {
return `${this.fmtNumber(distanceValue(value), 0)} ${distanceUnit()}`;
},
async changeVehicle(index) {
async changeVehicle(name) {
await this.updateSession({
vehicle: this.vehicles[index - 1].title,
vehicle: this.vehicles.find((v) => v.name === name)?.title,
});
},
async removeVehicle() {

View file

@ -4,11 +4,11 @@
<slot name="label">{{ label }}</slot>
</div>
<slot>
<h3 class="value m-0 d-block d-sm-flex align-items-baseline" :class="valueClass">
<h3 class="value m-0" :class="valueClass">
<slot name="value">
<AnimatedNumber v-if="valueFmt" :to="value" :format="valueFmt" />
<span v-else>{{ value }}</span>
<div v-if="extraValue" class="extraValue ms-0 ms-sm-1 text-nowrap">
<div v-if="extraValue" class="extraValue text-nowrap">
{{ extraValue }}
</div>
</slot>

View file

@ -0,0 +1,100 @@
<template>
<LabelAndValue
class="flex-grow-1"
:label="$t('main.targetEnergy.label')"
align="end"
data-testid="limit-energy"
>
<h3 class="value m-0">
<label class="position-relative">
<select :value="limitEnergy" class="custom-select" @change="change">
<option
v-for="{ energy, text, disabled } in options"
:key="energy"
:value="energy"
:disabled="disabled"
>
{{ text }}
</option>
</select>
<span
class="text-decoration-underline"
:class="{ 'text-gray fw-normal': !limitEnergy }"
data-testid="limit-energy-value"
>
<AnimatedNumber :to="limitEnergy" :format="fmtEnergy" />
</span>
</label>
<div v-if="estimated" class="extraValue text-nowrap">
<AnimatedNumber :to="estimated" :format="fmtSoc" />
</div>
</h3>
</LabelAndValue>
</template>
<script>
import LabelAndValue from "./LabelAndValue.vue";
import AnimatedNumber from "./AnimatedNumber.vue";
import formatter from "../mixins/formatter";
import { estimatedSoc, energyOptions, optionStep, fmtEnergy } from "../utils/energyOptions";
export default {
name: "LimitEnergySelect",
components: { LabelAndValue, AnimatedNumber },
mixins: [formatter],
props: {
limitEnergy: Number,
socPerKwh: Number,
chargedEnergy: Number,
vehicleCapacity: Number,
},
emits: ["limit-energy-updated"],
computed: {
options: function () {
return energyOptions(
this.chargedEnergy,
this.vehicleCapacity || 100,
this.socPerKwh,
this.fmtKWh,
this.$t("main.targetEnergy.noLimit")
);
},
step() {
return optionStep(this.vehicleCapacity || 100);
},
estimated: function () {
return estimatedSoc(this.limitEnergy, this.socPerKwh);
},
},
methods: {
change: function (e) {
return this.$emit("limit-energy-updated", parseFloat(e.target.value));
},
fmtEnergy: function (value) {
return fmtEnergy(value, this.step, this.fmtKWh, this.$t("main.targetEnergy.noLimit"));
},
fmtSoc: function (value) {
return `+${Math.round(value)}%`;
},
},
};
</script>
<style scoped>
.value {
font-size: 18px;
}
.extraValue {
color: var(--evcc-gray);
font-size: 14px;
}
.custom-select {
left: 0;
top: 0;
bottom: 0;
right: 0;
position: absolute;
opacity: 0;
}
</style>

View file

@ -1,18 +1,18 @@
<template>
<LabelAndValue class="flex-grow-1" :label="title" align="end" data-testid="target-soc">
<h3 class="value m-0 d-block d-sm-flex align-items-baseline justify-content-end">
<LabelAndValue class="flex-grow-1" :label="title" align="end" data-testid="limit-soc">
<h3 class="value m-0">
<label class="position-relative">
<select :value="targetSoc" class="custom-select" @change="change">
<select :value="limitSoc" class="custom-select" @change="change">
<option v-for="{ soc, text } in options" :key="soc" :value="soc">
{{ text }}
</option>
</select>
<span class="text-decoration-underline" data-testid="target-soc-value">
<AnimatedNumber :to="targetSoc" :format="formatSoc" />
<span class="text-decoration-underline" data-testid="limit-soc-value">
<AnimatedNumber :to="limitSoc" :format="formatSoc" />
</span>
</label>
<div v-if="estimatedTargetRange" class="extraValue ms-0 ms-sm-1 text-nowrap">
<div v-if="estimatedTargetRange" class="extraValue text-nowrap">
<AnimatedNumber :to="estimatedTargetRange" :format="formatKm" />
</div>
</h3>
@ -26,25 +26,21 @@ import { distanceUnit } from "../units";
import formatter from "../mixins/formatter";
export default {
name: "TargetSocSelect",
name: "LimitSocSelect",
components: { LabelAndValue, AnimatedNumber },
mixins: [formatter],
props: {
targetSoc: Number,
limitSoc: Number,
rangePerSoc: Number,
heating: Boolean,
},
emits: ["target-soc-updated"],
emits: ["limit-soc-updated"],
computed: {
options: function () {
const result = [];
for (let soc = 20; soc <= 100; soc += 5) {
let text = this.formatSoc(soc);
const range = this.estimatedRange(soc);
if (range) {
text += ` (${this.formatKm(range)})`;
}
let text = this.fmtSocOption(soc, this.rangePerSoc, distanceUnit(), this.heating);
result.push({ soc, text });
}
return result;
@ -55,12 +51,12 @@ export default {
: this.$t("main.vehicle.targetSoc");
},
estimatedTargetRange: function () {
return this.estimatedRange(this.targetSoc);
return this.estimatedRange(this.limitSoc);
},
},
methods: {
change: function (e) {
return this.$emit("target-soc-updated", parseInt(e.target.value, 10));
return this.$emit("limit-soc-updated", parseInt(e.target.value, 10));
},
estimatedRange: function (soc) {
if (this.rangePerSoc) {

View file

@ -15,7 +15,7 @@ const state = reactive({
mode: "pv",
charging: true,
vehicleSoc: 66,
targetSoc: 90,
limitSoc: 90,
chargeCurrent: 7,
minCurrent: 6,
maxCurrent: 16,

View file

@ -83,14 +83,11 @@
<hr class="divider" />
<Vehicle
class="flex-grow-1 d-flex flex-column justify-content-end"
v-bind="vehicle"
@target-soc-updated="setTargetSoc"
@target-energy-updated="setTargetEnergy"
@target-time-updated="setTargetTime"
@target-time-removed="removeTargetTime"
v-bind="vehicleProps"
@limit-soc-updated="setLimitSoc"
@limit-energy-updated="setLimitEnergy"
@change-vehicle="changeVehicle"
@remove-vehicle="removeVehicle"
@minsoc-updated="setMinSoc"
/>
</div>
</template>
@ -130,8 +127,8 @@ export default {
// main
title: String,
mode: String,
targetSoc: Number,
targetEnergy: Number,
effectiveLimitSoc: Number,
limitEnergy: Number,
remoteDisabled: Boolean,
remoteDisabledSource: String,
chargeDuration: Number,
@ -157,24 +154,27 @@ export default {
vehiclePresent: Boolean,
vehicleRange: Number,
vehicleSoc: Number,
vehicleName: String,
vehicleTitle: String,
vehicleIcon: String,
vehicleTargetSoc: Number,
vehicleCapacity: Number,
vehicleFeatureOffline: Boolean,
vehicles: Array,
minSoc: Number,
planActive: Boolean,
planProjectedStart: String,
targetTime: String,
planEnergy: Number,
planTime: String,
effectivePlanTime: String,
effectivePlanSoc: Number,
vehicleProviderLoggedIn: Boolean,
vehicleProviderLoginPath: String,
vehicleProviderLogoutPath: String,
// details
vehicleClimaterActive: Boolean,
chargePower: Number,
chargedEnergy: Number,
climaterActive: Boolean,
chargeRemainingDuration: Number,
// other information
@ -211,6 +211,9 @@ export default {
};
},
computed: {
vehicle: function () {
return this.vehicles?.find((v) => v.name === this.vehicleName);
},
loadpointTitle: function () {
return this.title || this.$t("main.loadpoint.fallbackName");
},
@ -232,7 +235,7 @@ export default {
settingsButtonVisible: function () {
return this.$hiddenFeatures() || [0, 1, 3].includes(this.phasesConfigured);
},
vehicle: function () {
vehicleProps: function () {
return this.collectProps(Vehicle);
},
showChargingIndicator: function () {
@ -241,6 +244,9 @@ export default {
socBasedCharging: function () {
return (!this.vehicleFeatureOffline && this.vehiclePresent) || this.vehicleSoc > 0;
},
socBasedPlanning: function () {
return this.vehicleSoc > 0 && this.vehicleCapacity > 0;
},
},
watch: {
phaseRemaining() {
@ -289,11 +295,11 @@ export default {
setTargetMode: function (mode) {
api.post(this.apiPath("mode") + "/" + mode);
},
setTargetSoc: function (soc) {
api.post(this.apiPath("target/soc") + "/" + soc);
setLimitSoc: function (soc) {
api.post(this.apiPath("limitsoc") + "/" + soc);
},
setTargetEnergy: function (kWh) {
api.post(this.apiPath("target/energy") + "/" + kWh);
setLimitEnergy: function (kWh) {
api.post(this.apiPath("limitenergy") + "/" + kWh);
},
setMaxCurrent: function (maxCurrent) {
api.post(this.apiPath("maxcurrent") + "/" + maxCurrent);
@ -304,17 +310,8 @@ export default {
setPhasesConfigured: function (phases) {
api.post(this.apiPath("phases") + "/" + phases);
},
setMinSoc: function (soc) {
api.post(this.apiPath("minsoc") + "/" + soc);
},
setTargetTime: function (date) {
api.post(`${this.apiPath("target/time")}/${date.toISOString()}`);
},
removeTargetTime: function () {
api.delete(this.apiPath("target/time"));
},
changeVehicle(index) {
api.post(this.apiPath("vehicle") + `/${index}`);
changeVehicle(name) {
api.post(this.apiPath("vehicle") + `/${name}`);
},
removeVehicle() {
api.delete(this.apiPath("vehicle"));

View file

@ -16,7 +16,7 @@ function loadpoint(opts) {
mode: "pv",
charging: true,
vehicleSoc: 66,
targetSoc: 90,
limitSoc: 90,
chargeCurrent: 7,
minCurrent: 6,
maxCurrent: 16,

View file

@ -20,7 +20,7 @@
<Loadpoints
class="mt-1 mt-sm-2 flex-grow-1"
:loadpoints="loadpoints"
:vehicles="vehicles"
:vehicles="vehicleList"
:smartCostLimit="smartCostLimit"
:smartCostType="smartCostType"
:smartCostActive="smartCostActive"
@ -77,7 +77,7 @@ export default {
bufferSoc: Number,
bufferStartSoc: Number,
siteTitle: String,
vehicles: Array,
vehicles: Object,
auth: Object,
@ -117,6 +117,10 @@ export default {
return { icon, charging, power };
});
},
vehicleList: function () {
const vehicles = this.vehicles || {};
return Object.entries(vehicles).map(([name, vehicle]) => ({ name, ...vehicle }));
},
topNavigation: function () {
const vehicleLogins = this.auth ? this.auth.vehicles : {};
return { vehicleLogins, ...this.collectProps(TopNavigation) };

View file

@ -1,331 +0,0 @@
<template>
<form @submit.prevent="setTargetTime">
<div class="mt-4">
<div class="form-group d-lg-flex align-items-baseline mb-3 justify-content-between">
<!-- eslint-disable vue/no-v-html -->
<label :for="`targetTimeLabel${id}`" class="mb-3 me-3">
<span v-if="socBasedCharging">
{{
$t("main.targetCharge.descriptionSoc", {
targetSoc,
})
}}
</span>
<span v-else>
{{
$t("main.targetCharge.descriptionEnergy", {
targetEnergy: targetEnergyFormatted,
})
}}
</span>
</label>
<!-- eslint-enable vue/no-v-html -->
<div class="d-flex justify-content-between date-selection">
<select
:id="`targetTimeLabel${id}`"
v-model="selectedDay"
class="form-select me-2"
data-testid="target-day"
>
<option v-for="opt in dayOptions()" :key="opt.value" :value="opt.value">
{{ opt.name }}
</option>
</select>
<input
v-model="selectedTime"
type="time"
class="form-control ms-2 time-selection"
:step="60 * 5"
required
data-testid="target-time"
/>
</div>
</div>
<p class="my-3 pb-1">
<span v-if="timeInThePast" class="d-block text-danger mb-1">
{{ $t("main.targetCharge.targetIsInThePast") }}
</span>
<span v-if="!socBasedCharging && !targetEnergy" class="d-block text-danger mb-1">
{{ $t("main.targetCharge.targetEnergyRequired") }}
</span>
<span v-if="vehicleCapacityRequired" class="d-block text-danger mb-1">
{{ $t("main.targetCharge.vehicleCapacityRequired") }}
<a
class="d-inline text-danger"
href="https://docs.evcc.io/en/docs/reference/configuration/vehicles/#capacity"
target="_blank"
>
{{ $t("main.targetCharge.vehicleCapacityDocs") }}
</a>
</span>
<span v-if="timeTooFarInTheFuture" class="d-block text-secondary mb-1">
{{ $t("main.targetCharge.targetIsTooFarInTheFuture") }}
</span>
<span v-if="costLimitExists" class="d-block text-secondary mb-1">
{{
$t("main.targetCharge.costLimitIgnore", {
limit: costLimitText,
})
}}
</span>
<span v-if="['off', 'now'].includes(mode)" class="d-block text-secondary mb-1">
{{ $t("main.targetCharge.onlyInPvMode") }}
</span>
</p>
<TargetChargePlan v-if="targetChargePlanProps" v-bind="targetChargePlanProps" />
<div class="d-flex justify-content-between mt-3">
<button
type="button"
class="btn btn-outline-secondary"
:disabled="!targetTime"
@click="removeTargetTime"
>
{{ $t("main.targetCharge.remove") }}
</button>
<button
type="submit"
class="btn btn-primary"
:disabled="timeInThePast || vehicleCapacityRequired"
>
<span v-if="targetTime">
{{ $t("main.targetCharge.update") }}
</span>
<span v-else>
{{ $t("main.targetCharge.activate") }}
</span>
</button>
</div>
</div>
</form>
</template>
<script>
import "@h2d2/shopicons/es/filled/plus";
import "@h2d2/shopicons/es/filled/edit";
import { CO2_TYPE } from "../units";
import TargetChargePlan from "./TargetChargePlan.vue";
import api from "../api";
import formatter from "../mixins/formatter";
const DEFAULT_TARGET_TIME = "7:00";
const LAST_TARGET_TIME_KEY = "last_target_time";
export default {
name: "TargetCharge",
components: { TargetChargePlan },
mixins: [formatter],
props: {
id: [String, Number],
planActive: Boolean,
targetTime: String,
targetSoc: Number,
targetEnergy: Number,
socBasedCharging: Boolean,
disabled: Boolean,
smartCostLimit: Number,
smartCostType: String,
currency: String,
mode: String,
vehicleCapacity: Number,
},
emits: ["target-time-updated", "target-time-removed"],
data: function () {
return {
selectedDay: null,
selectedTime: null,
plan: {},
tariff: {},
activeTab: "time",
loading: false,
};
},
computed: {
timeTabActive: function () {
return this.activeTab === "time";
},
priceTabActive: function () {
return this.activeTab === "price";
},
targetChargeEnabled: function () {
return this.targetTime;
},
timeInThePast: function () {
const now = new Date();
return now >= this.selectedTargetTime;
},
timeTooFarInTheFuture: function () {
if (this.tariff?.rates) {
const lastRate = this.tariff.rates[this.tariff.rates.length - 1];
if (lastRate?.end) {
const end = new Date(lastRate.end);
return this.selectedTargetTime >= end;
}
}
return false;
},
selectedTargetTime: function () {
return new Date(`${this.selectedDay}T${this.selectedTime || "00:00"}`);
},
targetEnergyFormatted: function () {
return this.fmtKWh(this.targetEnergy * 1e3, true, true, 1);
},
targetChargePlanProps: function () {
const targetTime = this.selectedTargetTime;
const { rates } = this.tariff;
const { duration, plan } = this.plan;
const { currency, smartCostType } = this;
return rates ? { duration, rates, plan, targetTime, currency, smartCostType } : null;
},
tariffLowest: function () {
return this.tariff?.rates.reduce((res, slot) => {
return Math.min(res, slot.price);
}, Number.MAX_VALUE);
},
tariffHighest: function () {
return this.tariff?.rates.reduce((res, slot) => {
return Math.max(res, slot.price);
}, 0);
},
costLimitExists: function () {
return this.smartCostLimit !== 0;
},
costLimitText: function () {
if (this.isCo2) {
return this.$t("main.targetCharge.co2Limit", {
co2: this.fmtCo2Short(this.smartCostLimit),
});
}
return this.$t("main.targetCharge.priceLimit", {
price: this.fmtPricePerKWh(this.smartCostLimit, this.currency, true),
});
},
isCo2() {
return this.smartCostType === CO2_TYPE;
},
vehicleCapacityRequired() {
return this.socBasedCharging && !this.vehicleCapacity;
},
},
watch: {
targetTime() {
this.initInputFields();
this.updatePlan();
},
selectedTargetTime() {
this.updatePlan();
},
targetSoc() {
this.updatePlan();
},
targetEnergy() {
this.updatePlan();
},
},
mounted() {
this.initInputFields();
this.updatePlan();
},
methods: {
updatePlan: async function () {
if (
!this.timeInThePast &&
(this.targetEnergy || this.targetSoc) &&
!isNaN(this.selectedTargetTime) &&
!this.loading
) {
try {
this.loading = true;
this.plan = (
await api.get(`loadpoints/${this.id}/target/plan`, {
params: { targetTime: this.selectedTargetTime },
})
).data.result;
const tariffRes = await api.get(`tariff/planner`, {
validateStatus: function (status) {
return status >= 200 && status < 500;
},
});
this.tariff = tariffRes.status === 404 ? { rates: [] } : tariffRes.data.result;
} catch (e) {
console.error(e);
} finally {
this.loading = false;
}
}
},
defaultDate: function () {
const [hours, minutes] = (
window.localStorage[LAST_TARGET_TIME_KEY] || DEFAULT_TARGET_TIME
).split(":");
const target = new Date();
target.setSeconds(0);
target.setMinutes(minutes);
target.setHours(hours);
// today or tomorrow?
const isInPast = target < new Date();
if (isInPast) {
target.setDate(target.getDate() + 1);
}
return target;
},
initInputFields: function () {
let date = this.defaultDate();
let targetTimeInTheFuture = new Date(this.targetTime) > new Date();
if (this.targetChargeEnabled && targetTimeInTheFuture) {
date = new Date(this.targetTime);
}
this.selectedDay = this.fmtDayString(date);
this.selectedTime = this.fmtTimeString(date);
},
dayOptions: function () {
const options = [];
const date = new Date();
const labels = [
this.$t("main.targetCharge.today"),
this.$t("main.targetCharge.tomorrow"),
];
for (let i = 0; i < 7; i++) {
const dayNumber = date.toLocaleDateString(this.$i18n.locale, {
month: "short",
day: "numeric",
});
const dayName =
labels[i] || date.toLocaleDateString(this.$i18n.locale, { weekday: "long" });
options.push({
value: this.fmtDayString(date),
name: `${dayNumber} (${dayName})`,
});
date.setDate(date.getDate() + 1);
}
return options;
},
setTargetTime: function () {
try {
const hours = this.selectedTargetTime.getHours();
const minutes = this.selectedTargetTime.getMinutes();
window.localStorage[LAST_TARGET_TIME_KEY] = `${hours}:${minutes}`;
} catch (e) {
console.warn(e);
}
this.$emit("target-time-updated", this.selectedTargetTime);
},
removeTargetTime: function () {
this.$emit("target-time-removed");
},
},
};
</script>
<style scoped>
@media (min-width: 992px) {
.date-selection {
width: 370px;
}
}
.time-selection {
flex-basis: 200px;
}
</style>

View file

@ -1,124 +0,0 @@
<template>
<LabelAndValue
class="flex-grow-1"
:label="$t('main.targetEnergy.label')"
align="end"
data-testid="target-energy"
>
<h3 class="value m-0 d-block d-sm-flex align-items-baseline justify-content-end">
<label class="position-relative">
<select :value="targetEnergy" class="custom-select" @change="change">
<option
v-for="{ energy, text, disabled } in options"
:key="energy"
:value="energy"
:disabled="disabled"
>
{{ text }}
</option>
</select>
<span
class="text-decoration-underline"
:class="{ 'text-gray fw-normal': !targetEnergy }"
data-testid="target-energy-value"
>
<AnimatedNumber :to="targetEnergy" :format="fmtEnergy" />
</span>
</label>
<div v-if="estimatedTargetSoc" class="extraValue ms-0 ms-sm-1 text-nowrap">
<AnimatedNumber :to="estimatedTargetSoc" :format="fmtSoc" />
</div>
</h3>
</LabelAndValue>
</template>
<script>
import LabelAndValue from "./LabelAndValue.vue";
import AnimatedNumber from "./AnimatedNumber.vue";
import formatter from "../mixins/formatter";
export default {
name: "TargetEnergySelect",
components: { LabelAndValue, AnimatedNumber },
mixins: [formatter],
props: {
targetEnergy: Number,
socPerKwh: Number,
chargedEnergy: Number,
vehicleCapacity: Number,
},
emits: ["target-energy-updated"],
computed: {
maxEnergy: function () {
return this.vehicleCapacity || 100;
},
steps: function () {
if (this.maxEnergy < 1) return 0.05;
if (this.maxEnergy < 2) return 0.1;
if (this.maxEnergy < 5) return 0.25;
if (this.maxEnergy < 10) return 0.5;
if (this.maxEnergy < 25) return 1;
if (this.maxEnergy < 50) return 2;
return 5;
},
options: function () {
const result = [];
for (let energy = 0; energy <= this.maxEnergy; energy += this.steps) {
let text = this.fmtEnergy(energy);
const disabled = energy < this.chargedEnergy / 1e3 && energy !== 0;
const soc = this.estimatedSoc(energy);
if (soc) {
text += ` (${this.fmtSoc(soc)})`;
}
result.push({ energy, text, disabled });
}
return result;
},
estimatedTargetSoc: function () {
return this.estimatedSoc(this.targetEnergy);
},
},
methods: {
change: function (e) {
return this.$emit("target-energy-updated", parseFloat(e.target.value));
},
estimatedSoc: function (kWh) {
if (this.socPerKwh) {
return Math.round(kWh * this.socPerKwh);
}
return null;
},
fmtEnergy: function (value) {
if (value === 0) {
return this.$t("main.targetEnergy.noLimit");
}
const inKWh = this.steps >= 0.1;
const digits = inKWh && this.steps < 1 ? 1 : 0;
return this.fmtKWh(value * 1e3, inKWh, true, digits);
},
fmtSoc: function (value) {
return `+${Math.round(value)}%`;
},
},
};
</script>
<style scoped>
.value {
font-size: 18px;
}
.extraValue {
color: var(--evcc-gray);
font-size: 14px;
}
.custom-select {
left: 0;
top: 0;
bottom: 0;
right: 0;
position: absolute;
opacity: 0;
}
</style>

View file

@ -9,7 +9,7 @@ const state = reactive({
vehiclePresent: true,
vehicleSoc: 42.742,
vehicleRange: 231,
targetSoc: 90,
limitSoc: 90,
vehicleCapacity: 72,
chargedEnergy: 14123,
socBasedCharging: true,

View file

@ -10,8 +10,9 @@
<VehicleSoc
v-bind="vehicleSocProps"
class="mt-2 mb-4"
@target-soc-updated="targetSocUpdated"
@target-soc-drag="targetSocDrag"
@limit-soc-updated="limitSocUpdated"
@limit-soc-drag="limitSocDrag"
@plan-clicked="openPlanModal"
/>
<div class="details d-flex flex-wrap justify-content-between">
@ -33,29 +34,27 @@
/>
<ChargingPlan
v-if="!heating"
ref="chargingPlan"
class="flex-grow-1 target-charge"
v-bind="chargingPlan"
:disabled="chargingPlanDisabled"
@target-time-updated="setTargetTime"
@target-time-removed="removeTargetTime"
@minsoc-updated="setMinSoc"
/>
<TargetSocSelect
<LimitSocSelect
v-if="socBasedCharging"
class="flex-grow-1 text-end"
:target-soc="displayTargetSoc"
:limit-soc="displayLimitSoc"
:range-per-soc="rangePerSoc"
:heating="heating"
@target-soc-updated="targetSocUpdated"
@limit-soc-updated="limitSocUpdated"
/>
<TargetEnergySelect
<LimitEnergySelect
v-else
class="flex-grow-1 text-end"
:target-energy="targetEnergy"
:limit-energy="limitEnergy"
:soc-per-kwh="socPerKwh"
:charged-energy="chargedEnergy"
:vehicle-capacity="vehicleCapacity"
@target-energy-updated="targetEnergyUpdated"
@limit-energy-updated="limitEnergyUpdated"
/>
</div>
</div>
@ -69,8 +68,8 @@ import VehicleTitle from "./VehicleTitle.vue";
import VehicleSoc from "./VehicleSoc.vue";
import VehicleStatus from "./VehicleStatus.vue";
import ChargingPlan from "./ChargingPlan.vue";
import TargetSocSelect from "./TargetSocSelect.vue";
import TargetEnergySelect from "./TargetEnergySelect.vue";
import LimitSocSelect from "./LimitSocSelect.vue";
import LimitEnergySelect from "./LimitEnergySelect.vue";
import { distanceUnit, distanceValue } from "../units";
export default {
@ -81,64 +80,64 @@ export default {
VehicleStatus,
LabelAndValue,
ChargingPlan,
TargetSocSelect,
TargetEnergySelect,
LimitSocSelect,
LimitEnergySelect,
},
mixins: [collector, formatter],
props: {
id: [String, Number],
connected: Boolean,
integratedDevice: Boolean,
heating: Boolean,
vehiclePresent: Boolean,
vehicleSoc: Number,
vehicleTargetSoc: Number,
enabled: Boolean,
charging: Boolean,
minSoc: Number,
vehicleDetectionActive: Boolean,
vehicleRange: Number,
vehicleTitle: String,
vehicleIcon: String,
vehicleCapacity: Number,
socBasedCharging: Boolean,
planActive: Boolean,
planProjectedStart: String,
targetTime: String,
targetSoc: Number,
targetEnergy: Number,
chargedEnergy: Number,
charging: Boolean,
vehicleClimaterActive: Boolean,
connected: Boolean,
currency: String,
effectiveLimitSoc: Number,
effectivePlanSoc: Number,
effectivePlanTime: String,
enabled: Boolean,
guardAction: String,
guardRemainingInterpolated: Number,
heating: Boolean,
id: [String, Number],
integratedDevice: Boolean,
limitEnergy: Number,
mode: String,
phaseAction: String,
phaseRemainingInterpolated: Number,
planActive: Boolean,
planEnergy: Number,
planProjectedStart: String,
planTime: String,
pvAction: String,
pvRemainingInterpolated: Number,
guardAction: String,
guardRemainingInterpolated: Number,
vehicles: Array,
climaterActive: Boolean,
smartCostActive: Boolean,
smartCostLimit: Number,
smartCostType: String,
smartCostActive: Boolean,
tariffGrid: Number,
socBasedCharging: Boolean,
socBasedPlanning: Boolean,
tariffCo2: Number,
currency: String,
tariffGrid: Number,
vehicle: Object,
vehicleCapacity: Number,
vehicleDetectionActive: Boolean,
vehicleIcon: String,
vehicleName: String,
vehiclePresent: Boolean,
vehicleRange: Number,
vehicles: Array,
vehicleSoc: Number,
vehicleTargetSoc: Number,
vehicleTitle: String,
},
emits: [
"target-time-removed",
"target-time-updated",
"target-soc-updated",
"target-energy-updated",
"change-vehicle",
"remove-vehicle",
"minsoc-updated",
],
emits: ["limit-soc-updated", "limit-energy-updated", "change-vehicle", "remove-vehicle"],
data() {
return {
displayTargetSoc: this.targetSoc,
displayLimitSoc: this.effectiveLimitSoc,
};
},
computed: {
minSoc: function () {
return this.vehicle?.minSoc || 0;
},
vehicleSocProps: function () {
return this.collectProps(VehicleSoc);
},
@ -196,45 +195,27 @@ export default {
if (["off", "now"].includes(this.mode)) {
return true;
}
// enabled for vehicles with Soc
if (this.socBasedCharging) {
return false;
}
// disabled of no energy target is set (offline or guest vehicles)
if (!this.targetEnergy) {
return true;
}
return false;
},
},
watch: {
targetSoc: function () {
this.displayTargetSoc = this.targetSoc;
effectiveLimitSoc: function () {
this.displayLimitSoc = this.effectiveLimitSoc;
},
},
methods: {
targetSocDrag: function (targetSoc) {
this.displayTargetSoc = targetSoc;
limitSocDrag: function (limitSoc) {
this.displayLimitSoc = limitSoc;
},
targetSocUpdated: function (targetSoc) {
this.displayTargetSoc = targetSoc;
this.$emit("target-soc-updated", targetSoc);
limitSocUpdated: function (limitSoc) {
this.displayLimitSoc = limitSoc;
this.$emit("limit-soc-updated", limitSoc);
},
targetEnergyUpdated: function (targetEnergy) {
this.$emit("target-energy-updated", targetEnergy);
limitEnergyUpdated: function (limitEnergy) {
this.$emit("limit-energy-updated", limitEnergy);
},
setTargetTime: function (targetTime) {
this.$emit("target-time-updated", targetTime);
},
setMinSoc: function (minSoc) {
this.$emit("minsoc-updated", minSoc);
},
removeTargetTime: function () {
this.$emit("target-time-removed");
},
changeVehicle(index) {
this.$emit("change-vehicle", index);
changeVehicle(name) {
this.$emit("change-vehicle", name);
},
removeVehicle() {
this.$emit("remove-vehicle");
@ -243,6 +224,9 @@ export default {
const inKw = value == 0 || value >= 1000;
return this.fmtKWh(value, inKw);
},
openPlanModal() {
this.$refs.chargingPlan.openPlanModal();
},
},
};
</script>

View file

@ -6,6 +6,7 @@
tabindex="0"
data-bs-toggle="dropdown"
aria-expanded="false"
data-testid="change-vehicle"
>
<slot />
</div>
@ -13,8 +14,8 @@
<li>
<h6 class="dropdown-header">{{ $t("main.vehicle.changeVehicle") }}</h6>
</li>
<li v-for="vehicle in vehicles" :key="vehicle">
<button type="button" class="dropdown-item" @click="changeVehicle(vehicle.id)">
<li v-for="vehicle in vehicles" :key="vehicle.name">
<button type="button" class="dropdown-item" @click="changeVehicle(vehicle.name)">
{{ vehicle.title }}
</button>
</li>
@ -51,8 +52,8 @@ export default {
this.dropdown?.dispose();
},
methods: {
changeVehicle(index) {
this.$emit("change-vehicle", index + 1);
changeVehicle(name) {
this.$emit("change-vehicle", name);
},
removeVehicle() {
this.$emit("remove-vehicle");

View file

@ -10,24 +10,42 @@
'progress-bar-striped': charging,
'progress-bar-animated': charging,
}"
:style="{ width: `${vehicleSocDisplayWidth}%` }"
:style="{ width: `${vehicleSocDisplayWidth}%`, ...transition }"
></div>
<div
v-if="remainingSocWidth > 0 && enabled && connected"
class="progress-bar bg-muted"
role="progressbar"
:class="progressColor"
:style="{ width: `${remainingSocWidth}%`, transition: 'none' }"
:style="{ width: `${remainingSocWidth}%`, ...transition }"
></div>
<div
v-show="vehicleTargetSoc"
ref="vehicleTargetSoc"
class="vehicle-target-soc"
class="vehicle-limit-soc"
data-bs-toggle="tooltip"
title=" "
:class="{ 'vehicle-target-soc--active': vehicleTargetSocActive }"
:class="{ 'vehicle-limit-soc--active': vehicleTargetSocActive }"
:style="{ left: `${vehicleTargetSoc}%` }"
/>
<div
v-show="energyLimitMarkerPosition"
class="energy-limit-marker"
data-bs-toggle="tooltip"
:class="{ 'energy-limit-marker--active': energyLimitMarkerPosition < 100 }"
:style="{ left: `${energyLimitMarkerPosition}%` }"
/>
<div
v-show="planMarkerAvailable"
class="plan-marker"
data-bs-toggle="tooltip"
:class="{ 'plan-marker--warn': !planMarkerActive }"
:style="{ left: `${planMarkerPosition}%` }"
data-testid="plan-marker"
@click="$emit('plan-clicked')"
>
<shopicon-regular-clock></shopicon-regular-clock>
</div>
</div>
<div class="target">
<input
@ -36,14 +54,14 @@
min="0"
max="100"
step="5"
:value="visibleTargetSoc"
class="target-slider"
:class="{ 'target-slider--active': targetSliderActive }"
@mousedown="changeTargetSocStart"
@touchstart="changeTargetSocStart"
@input="movedTargetSoc"
@mouseup="changeTargetSocEnd"
@touchend="changeTargetSocEnd"
:value="visibleLimitSoc"
class="slider"
:class="{ 'slider--active': sliderActive }"
@mousedown="changeLimitSocStart"
@touchstart="changeLimitSocStart"
@input="movedLimitSoc"
@mouseup="changeLimitSocEnd"
@touchend="changeLimitSocEnd"
/>
</div>
</div>
@ -51,6 +69,7 @@
<script>
import Tooltip from "bootstrap/js/dist/tooltip";
import "@h2d2/shopicons/es/regular/clock";
export default {
name: "VehicleSoc",
@ -62,17 +81,21 @@ export default {
enabled: Boolean,
charging: Boolean,
minSoc: Number,
targetSoc: Number,
targetEnergy: Number,
effectivePlanSoc: Number,
effectiveLimitSoc: Number,
limitEnergy: Number,
planEnergy: Number,
chargedEnergy: Number,
socBasedCharging: Boolean,
socBasedPlanning: Boolean,
},
emits: ["target-soc-drag", "target-soc-updated"],
emits: ["limit-soc-drag", "limit-soc-updated", "plan-clicked"],
data: function () {
return {
selectedTargetSoc: null,
selectedLimitSoc: null,
interactionStartScreenY: null,
tooltip: null,
dragging: false,
};
},
computed: {
@ -83,17 +106,61 @@ export default {
}
return 100;
} else {
if (this.targetEnergy) {
return (100 / this.targetEnergy) * (this.chargedEnergy / 1e3);
if (this.maxEnergy) {
return (100 / this.maxEnergy) * (this.chargedEnergy / 1e3);
}
return 100;
}
},
transition: function () {
if (this.dragging) {
return { transition: "none" };
}
return { transition: "width var(--evcc-transition-fast) linear" };
},
maxEnergy: function () {
return Math.max(this.planEnergy, this.limitEnergy, this.chargedEnergy / 1e3);
},
vehicleTargetSocActive: function () {
return this.vehicleTargetSoc > 0 && this.vehicleTargetSoc > this.vehicleSoc;
},
targetSliderActive: function () {
return !this.vehicleTargetSoc || this.visibleTargetSoc <= this.vehicleTargetSoc;
planMarkerPosition: function () {
if (this.socBasedPlanning) {
return this.effectivePlanSoc;
}
const maxEnergy = Math.max(this.planEnergy, this.limitEnergy);
if (maxEnergy) {
return (100 / maxEnergy) * this.planEnergy;
}
return 0;
},
planMarkerAvailable: function () {
if (this.socBasedCharging && !this.socBasedPlanning) {
// mixed mode (% limit and kWh plan): hide marker
return false;
}
return this.planMarkerPosition > 0;
},
planMarkerActive: function () {
if (this.socBasedPlanning) {
const sessionLimit = this.selectedLimitSoc || this.effectiveLimitSoc;
const vehicleLimit = this.vehicleTargetSoc || 100;
const maxLimit = Math.min(sessionLimit, vehicleLimit);
return this.effectivePlanSoc <= maxLimit;
}
return this.planEnergy <= this.limitEnergy || !this.limitEnergy;
},
energyLimitMarkerPosition: function () {
if (this.socBasedCharging) {
return null;
}
if (this.limitEnergy) {
return (100 / this.maxEnergy) * this.limitEnergy;
}
return 100;
},
sliderActive: function () {
return !this.vehicleTargetSoc || this.visibleLimitSoc <= this.vehicleTargetSoc;
},
progressColor: function () {
if (this.minSocActive) {
@ -112,25 +179,26 @@ export default {
if (this.minSocActive) {
return this.minSoc - this.vehicleSoc;
}
let targetSoc = this.targetSliderActive
? this.visibleTargetSoc
: this.vehicleTargetSoc;
if (targetSoc > this.vehicleSoc) {
return targetSoc - this.vehicleSoc;
let soc = this.sliderActive ? this.visibleLimitSoc : this.vehicleTargetSoc;
if (soc > this.vehicleSoc) {
return soc - this.vehicleSoc;
}
} else {
if (this.limitEnergy && this.planEnergy > this.limitEnergy) {
return (100 / this.planEnergy) * this.limitEnergy - this.vehicleSocDisplayWidth;
}
return 100 - this.vehicleSocDisplayWidth;
}
return null;
},
visibleTargetSoc: function () {
return Number(this.selectedTargetSoc || this.targetSoc);
visibleLimitSoc: function () {
return Number(this.selectedLimitSoc || this.effectiveLimitSoc);
},
},
watch: {
targetSoc: function () {
this.selectedTargetSoc = this.targetSoc;
effectiveLimitSoc: function () {
this.selectedLimitSoc = this.effectiveLimitSoc;
},
vehicleTargetSoc: function () {
this.updateTooltip();
@ -140,29 +208,31 @@ export default {
this.updateTooltip();
},
methods: {
changeTargetSocStart: function (e) {
changeLimitSocStart: function (e) {
this.dragging = true;
e.stopPropagation();
},
changeTargetSocEnd: function (e) {
changeLimitSocEnd: function (e) {
this.dragging = false;
const value = parseInt(e.target.value, 10);
// value changed
if (value !== this.targetSoc) {
this.$emit("target-soc-updated", value);
if (value !== this.effectiveLimitSoc) {
this.$emit("limit-soc-updated", value);
}
},
movedTargetSoc: function (e) {
movedLimitSoc: function (e) {
let value = parseInt(e.target.value, 10);
e.stopPropagation();
const minTargetSoc = 20;
if (value < minTargetSoc) {
e.target.value = minTargetSoc;
this.selectedTargetSoc = value;
const minLimit = 20;
if (value < minLimit) {
e.target.value = minLimit;
this.selectedLimitSoc = value;
e.preventDefault();
return false;
}
this.selectedTargetSoc = value;
this.selectedLimitSoc = value;
this.$emit("target-soc-drag", this.selectedTargetSoc);
this.$emit("limit-soc-drag", this.selectedLimitSoc);
return true;
},
updateTooltip: function () {
@ -196,7 +266,7 @@ export default {
.bg-light {
color: var(--bs-gray-dark);
}
.target-slider {
.slider {
-webkit-appearance: none;
position: absolute;
top: calc(var(--thumb-overlap) * -1);
@ -205,24 +275,28 @@ export default {
background: transparent;
pointer-events: none;
}
.target-slider:focus {
.slider:focus {
outline: none;
}
/* Note: Safari,Chrome,Blink and Firefox specific styles need to be in separate definitions to work */
.target-slider::-webkit-slider-runnable-track {
.slider::-webkit-slider-runnable-track {
position: relative;
background: transparent;
border: none;
height: 100%;
cursor: auto;
/* ensure slider start/end is at the edge and not inside the track */
margin: 0 -6px;
}
.target-slider::-moz-range-track {
.slider::-moz-range-track {
background: transparent;
border: none;
height: 100%;
cursor: auto;
/* ensure slider start/end is at the edge and not inside the track */
margin: 0 -6px;
}
.target-slider::-webkit-slider-thumb {
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
position: relative;
margin-left: var(--thumb-width) / 2;
@ -237,7 +311,7 @@ export default {
pointer-events: auto;
transition: background-color var(--evcc-transition-fast) linear;
}
.target-slider::-moz-range-thumb {
.slider::-moz-range-thumb {
position: relative;
height: 100%;
width: var(--thumb-width);
@ -250,13 +324,13 @@ export default {
pointer-events: auto;
transition: background-color var(--evcc-transition-fast) linear;
}
.target-slider--active::-webkit-slider-thumb {
.slider--active::-webkit-slider-thumb {
background-color: var(--evcc-dark-green);
}
.target-slider--active::-moz-range-thumb {
.slider--active::-moz-range-thumb {
background-color: var(--evcc-dark-green);
}
.vehicle-target-soc {
.vehicle-limit-soc {
position: absolute;
top: 0;
bottom: 0;
@ -267,9 +341,54 @@ export default {
border-width: 0 8px;
border-style: solid;
border-color: transparent;
transition: background-color var(--evcc-transition-fast) linear;
transition-property: background-color, left;
transition-timing-function: linear;
transition-duration: var(--evcc-transition-fast);
}
.vehicle-target-soc--active {
.vehicle-limit-soc--active {
background-color: var(--evcc-box);
}
.plan-marker {
position: absolute;
top: 0;
transform: translateX(-50%);
color: var(--evcc-darker-green);
transition-property: color, left, opacity;
transition-timing-function: linear;
cursor: pointer;
transition-duration: var(--evcc-transition-fast);
}
.plan-marker::before {
content: "";
display: block;
height: var(--height);
border-width: 0 10px;
background-clip: padding-box;
border-style: solid;
border-color: transparent;
background-color: var(--evcc-darker-green);
transition: background-color var(--evcc-transition-fast) linear;
}
.plan-marker--warn {
opacity: 1;
color: var(--bs-danger);
}
.plan-marker--warn::before {
background-color: var(--bs-danger);
}
.energy-limit-marker {
position: absolute;
top: -4px;
bottom: -4px;
transform: translateX(-50%);
width: 4px;
opacity: 0;
background-color: var(--evcc-dark-green);
transition-property: opacity, left;
transition-timing-function: linear;
transition-duration: var(--evcc-transition-fast);
}
.energy-limit-marker--active {
opacity: 1;
}
</style>

View file

@ -42,43 +42,47 @@ describe("min charge", () => {
});
describe("plan", () => {
const targetTime = "2020-03-16T06:00:00Z";
const effectivePlanTime = "2020-03-16T06:00:00Z";
const planProjectedStart = "2020-03-16T02:00:00Z";
test("charging if target time is set, status is charging but planned slot is not active", () => {
expectStatus({ targetTime, charging: true, connected: true }, "charging");
expectStatus({ effectivePlanTime, charging: true, connected: true }, "charging");
});
test("active if target time is set, status is charging and planned slot is active", () => {
expectStatus(
{ targetTime, planActive: true, charging: true, connected: true },
{ effectivePlanTime, planActive: true, charging: true, connected: true },
"targetChargeActive"
);
});
test("waiting for vehicle if a target time is set, the charger is enabled but not charging", () => {
expectStatus(
{ targetTime, planActive: true, enabled: true, connected: true },
{ effectivePlanTime, planActive: true, enabled: true, connected: true },
"targetChargeWaitForVehicle"
);
});
test("show projected start if not enabled yet", () => {
expectStatus({ targetTime, planProjectedStart, connected: true }, "targetChargePlanned", {
time: "Mo 03:00",
});
expectStatus(
{ effectivePlanTime, planProjectedStart, connected: true },
"targetChargePlanned",
{
time: "Mo 03:00",
}
);
});
});
describe("climating", () => {
test("show climating status", () => {
expectStatus(
{ connected: true, enabled: true, climaterActive: true, charging: true },
{ connected: true, enabled: true, vehicleClimaterActive: true, charging: true },
"climating"
);
expectStatus(
{ connected: true, enabled: true, climaterActive: true, charging: false },
{ connected: true, enabled: true, vehicleClimaterActive: true, charging: false },
"climating"
);
});
test("only show climating if enabled", () => {
expectStatus({ connected: true, enabled: false, climaterActive: true }, "connected");
expectStatus({ connected: true, enabled: false, vehicleClimaterActive: true }, "connected");
});
});

View file

@ -16,7 +16,7 @@ export default {
enabled: Boolean,
connected: Boolean,
charging: Boolean,
targetTime: String,
effectivePlanTime: String,
planProjectedStart: String,
planActive: Boolean,
phaseAction: String,
@ -26,7 +26,7 @@ export default {
guardAction: String,
guardRemainingInterpolated: Number,
targetChargeDisabled: Boolean,
climaterActive: Boolean,
vehicleClimaterActive: Boolean,
smartCostLimit: Number,
smartCostType: String,
smartCostActive: Boolean,
@ -63,7 +63,7 @@ export default {
}
// plan
if (this.targetTime && !this.targetChargeDisabled) {
if (this.effectivePlanTime && !this.targetChargeDisabled) {
if (this.planActive && this.charging) {
return t("targetChargeActive");
}
@ -96,7 +96,7 @@ export default {
});
}
if (this.enabled && this.climaterActive) {
if (this.enabled && this.vehicleClimaterActive) {
return t("climating");
}

View file

@ -49,13 +49,14 @@ export default {
name: "VehicleTitle",
components: { VehicleOptions, VehicleIcon },
props: {
id: [String, Number],
vehiclePresent: Boolean,
vehicleTitle: String,
vehicleIcon: String,
vehicleDetectionActive: Boolean,
connected: Boolean,
id: [String, Number],
vehicleDetectionActive: Boolean,
vehicleIcon: String,
vehicleName: String,
vehiclePresent: Boolean,
vehicles: { type: Array, default: () => [] },
vehicleTitle: String,
},
emits: ["change-vehicle", "remove-vehicle"],
computed: {
@ -81,12 +82,7 @@ export default {
return !this.vehiclePresent;
},
otherVehicles() {
return this.vehicles
.map((v, id) => ({
id: id,
title: v,
}))
.filter((v) => v.title !== this.vehicleTitle);
return this.vehicles.filter((v) => v.name !== this.vehicleName);
},
showOptions() {
return !this.isUnknown || this.vehicles.length;
@ -101,8 +97,8 @@ export default {
this.tooltip();
},
methods: {
changeVehicle(index) {
this.$emit("change-vehicle", index);
changeVehicle(name) {
this.$emit("change-vehicle", name);
},
removeVehicle() {
this.$emit("remove-vehicle");

View file

@ -237,5 +237,13 @@ export default {
if (Math.abs(elapsed) > units[u] || u == "second")
return rtf.format(Math.round(elapsed / units[u]), u);
},
fmtSocOption: function (soc, rangePerSoc, distanceUnit, heating) {
let result = heating ? this.fmtNumber(soc, 1, "celcius") : `${this.fmtNumber(soc, 0)}%`;
if (rangePerSoc && distanceUnit) {
const range = soc * rangePerSoc;
result += ` (${this.fmtNumber(range, 0)} ${distanceUnit})`;
}
return result;
},
},
};

View file

@ -0,0 +1,40 @@
export function optionStep(maxEnergy) {
if (maxEnergy < 1) return 0.05;
if (maxEnergy < 2) return 0.1;
if (maxEnergy < 5) return 0.25;
if (maxEnergy < 10) return 0.5;
if (maxEnergy < 25) return 1;
if (maxEnergy < 50) return 2;
return 5;
}
export function fmtEnergy(energy, step, fmtKWh, zeroText) {
if (energy === 0) {
return zeroText;
}
const inKWh = step >= 0.1;
const digits = inKWh && step < 1 ? 1 : 0;
return fmtKWh(energy * 1e3, inKWh, true, digits);
}
export function estimatedSoc(energy, socPerKwh) {
if (!socPerKwh) return null;
return Math.round(energy * socPerKwh);
}
export function energyOptions(fromEnergy, maxEnergy, socPerKwh, fmtKWh, zeroText) {
const step = optionStep(maxEnergy);
const result = [];
for (let energy = 0; energy <= maxEnergy; energy += step) {
let text = fmtEnergy(energy, step, fmtKWh, zeroText);
const disabled = energy < fromEnergy / 1e3 && energy !== 0;
const soc = estimatedSoc(energy, socPerKwh);
if (soc) {
text += ` (+${Math.round(soc)}%)`;
}
// prevent rounding errors
const energyNormal = energy.toFixed(3) * 1;
result.push({ energy: energyNormal, text, disabled });
}
return result;
}

View file

@ -242,7 +242,7 @@
</main>
<ChargingSessionModal
:session="selectedSession"
:vehicles="vehiclesObjects"
:vehicles="vehicleList"
:currency="currency"
@session-changed="loadSessions"
/>
@ -518,12 +518,9 @@ export default {
vehicles() {
return [...new Set(this.currentSessions.map((s) => s.vehicle))];
},
vehiclesObjects() {
return (
store.state.vehicles?.map((v, index) => {
return { id: index, title: v };
}) || []
);
vehicleList() {
const vehicles = store.state.vehicles || {};
return Object.entries(vehicles).map(([name, vehicle]) => ({ name, ...vehicle }));
},
selectedSession() {
return this.sessions.find((s) => s.id == this.selectedSessionId);

View file

@ -12,6 +12,7 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/charger/eebus"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/provider"
"github.com/evcc-io/evcc/util"
)
@ -21,12 +22,17 @@ const (
voltage float64 = 230
)
type minMax struct {
min, max float64
}
type EEBus struct {
ski string
emobility emobility.EmobilityI
log *util.Logger
lp loadpoint.API
log *util.Logger
lp loadpoint.API
minMaxG func() (minMax, error)
communicationStandard emobility.EVCommunicationStandardType
@ -89,17 +95,21 @@ func NewEEBus(ski, ip string, hasMeter, hasChargedEnergy bool) (api.Charger, err
c.emobility = eebus.Instance.RegisterEVSE(ski, ip, c.onConnect, c.onDisconnect, nil)
err := c.waitForConnection()
c.minMaxG = provider.Cached(c.minMax, time.Second)
if err := c.waitForConnection(); err != nil {
return c, err
}
if hasMeter {
var energyG func() (float64, error)
if hasChargedEnergy {
energyG = c.chargedEnergy
}
return decorateEEBus(c, c.currentPower, c.currents, energyG), err
return decorateEEBus(c, c.currentPower, c.currents, energyG), nil
}
return c, err
return c, nil
}
// waitForConnection wait for initial connection and returns an error on failure
@ -162,27 +172,27 @@ func (c *EEBus) isConnected() bool {
return c.connected
}
func (c *EEBus) setLoadpointMinMaxLimits() {
if c.lp == nil {
return
}
var _ api.CurrentLimiter = (*EEBus)(nil)
func (c *EEBus) minMax() (minMax, error) {
minLimits, maxLimits, _, err := c.emobility.EVCurrentLimits()
if err != nil || len(minLimits) == 0 || len(maxLimits) == 0 {
return
if err != nil {
if err == features.ErrDataNotAvailable {
err = api.ErrNotAvailable
}
return minMax{}, err
}
newMin := minLimits[0]
newMax := maxLimits[0]
vehicle := c.lp.GetVehicle()
if c.lp.GetMinCurrent() != newMin && newMin > 0 && (vehicle == nil || vehicle.OnIdentified().MinCurrent == nil) {
c.lp.SetMinCurrent(newMin)
}
if c.lp.GetMaxCurrent() != newMax && newMax > 0 && (vehicle == nil || vehicle.OnIdentified().MaxCurrent == nil) {
c.lp.SetMaxCurrent(newMax)
if len(minLimits) == 0 || len(maxLimits) == 0 {
return minMax{}, api.ErrNotAvailable
}
return minMax{minLimits[0], maxLimits[0]}, nil
}
func (c *EEBus) GetMinMaxCurrent() (float64, float64, error) {
minMax, err := c.minMaxG()
return minMax.min, minMax.max, err
}
// we assume that if any phase current value is > idleFactor * min Current, then charging is active and enabled is true
@ -192,12 +202,11 @@ func (c *EEBus) isCharging() bool { // d *communication.EVSEClientDataType
// right now it works as expected
if c.lp != nil && c.lp.HasChargeMeter() {
// we only check ever 10 seconds, maybe we can use the config interval duration
timeDiff := time.Since(c.lastIsChargingCheck)
if timeDiff.Seconds() >= 10 {
if time.Since(c.lastIsChargingCheck) >= 10*time.Second {
c.lastIsChargingCheck = time.Now()
c.lastIsChargingResult = false
// compare charge power for all phases to 0.6 * min. charge power of a single phase
if c.lp.GetChargePower() > c.lp.GetMinPower()*idleFactor {
if c.lp.GetChargePower() > c.lp.EffectiveMinPower()*idleFactor {
c.lastIsChargingResult = true
return true
}
@ -222,13 +231,14 @@ func (c *EEBus) isCharging() bool { // d *communication.EVSEClientDataType
}
// require sum of all phase currents to be > 0.6 * a single phase minimum
// in some scenarions, e.g. Cayenne Hybrid, sometimes the meter of a PMCC device
// in some scenarios, e.g. Cayenne Hybrid, sometimes the meter of a PMCC device
// reported 600W, even tough the car was not charging
limitMin := limitsMin[0]
return phasesCurrent > limitMin*idleFactor
}
func (c *EEBus) updateState() (api.ChargeStatus, error) {
// Status implements the api.Charger interface
func (c *EEBus) Status() (api.ChargeStatus, error) {
if !c.isConnected() {
return api.StatusNone, api.ErrTimeout
}
@ -267,19 +277,11 @@ func (c *EEBus) updateState() (api.ChargeStatus, error) {
}
}
// Status implements the api.Charger interface
func (c *EEBus) Status() (api.ChargeStatus, error) {
// check the current limits and update if necessary
c.setLoadpointMinMaxLimits()
return c.updateState()
}
// Enabled implements the api.Charger interface
// should return true if the charger allows the EV to draw power
func (c *EEBus) Enabled() (bool, error) {
// when unplugged there is no overload limit data available
state, err := c.updateState()
state, err := c.Status()
if err != nil || state == api.StatusA {
return c.expectedEnableUnpluggedState, nil
}
@ -310,7 +312,7 @@ func (c *EEBus) Enabled() (bool, error) {
// Enable implements the api.Charger interface
func (c *EEBus) Enable(enable bool) error {
// if the ev is unplugged or the state is unknown, there is nothing to be done
if state, err := c.updateState(); err != nil || state == api.StatusA {
if state, err := c.Status(); err != nil || state == api.StatusA {
c.expectedEnableUnpluggedState = enable
return nil
}
@ -433,10 +435,7 @@ func (c *EEBus) chargedEnergy() (float64, error) {
return 0, err
}
// return kWh
energy /= 1000
return energy, nil
return energy / 1e3, nil
}
// Currents implements the api.PhaseCurrents interface
@ -448,7 +447,7 @@ func (c *EEBus) currents() (float64, float64, float64, error) {
currents, err := c.emobility.EVCurrentsPerPhase()
if err != nil {
if err == features.ErrDataNotAvailable {
return 0, 0, 0, api.ErrNotAvailable
err = api.ErrNotAvailable
}
return 0, 0, 0, err
}
@ -508,6 +507,4 @@ var _ loadpoint.Controller = (*EEBus)(nil)
// LoadpointControl implements loadpoint.Controller
func (c *EEBus) LoadpointControl(lp loadpoint.API) {
c.lp = lp
c.setLoadpointMinMaxLimits()
}

View file

@ -150,7 +150,7 @@ func (c *Twc3) MaxCurrent(current int64) error {
return errors.New("loadpoint not initialized")
}
v, ok := c.lp.GetVehicle().(api.CurrentLimiter)
v, ok := c.lp.GetVehicle().(api.CurrentController)
if !ok {
return errors.New("vehicle not capable of current control")
}

View file

@ -183,8 +183,6 @@ vehicles:
var range = (44 * lp.vehicleSoc) / 15;
range
capacity: 44
onidentify:
targetsoc: 90
- name: vehicle_2
title: weißes Model 3
type: custom
@ -210,8 +208,6 @@ vehicles:
script: |
"B"
capacity: 80
onidentify:
targetsoc: 75
- name: vehicle_3
type: template
template: offline

View file

@ -99,13 +99,13 @@ func runDump(cmd *cobra.Command, args []string) {
d.DumpWithHeader(fmt.Sprintf("grid: %s", name), handle(name, config.Meters()))
}
for id, name := range append(site.Meters.PVMetersRef, site.Meters.PVMetersRef_...) {
for id, name := range site.Meters.PVMetersRef {
if name != "" {
d.DumpWithHeader(fmt.Sprintf("pv %d: %s", id+1, name), handle(name, config.Meters()))
}
}
for id, name := range append(site.Meters.BatteryMetersRef, site.Meters.BatteryMetersRef_...) {
for id, name := range site.Meters.BatteryMetersRef {
if name != "" {
d.DumpWithHeader(fmt.Sprintf("battery %d: %s", id+1, name), handle(name, config.Meters()))
}
@ -117,7 +117,8 @@ func runDump(cmd *cobra.Command, args []string) {
}
}
for _, v := range site.GetVehicles() {
for _, vv := range site.Vehicles().All() {
v := vv.Instance()
d.DumpWithHeader(fmt.Sprintf("vehicle: %s", v.Title()), v)
}

View file

@ -204,8 +204,11 @@ func runRoot(cmd *cobra.Command, args []string) {
// setup mqtt publisher
if err == nil && conf.Mqtt.Broker != "" {
publisher := server.NewMQTT(strings.Trim(conf.Mqtt.Topic, "/"))
go publisher.Run(site, pipe.NewDropper(append(ignoreMqtt, ignoreEmpty)...).Pipe(tee.Attach()))
var mqtt *server.MQTT
mqtt, err = server.NewMQTT(strings.Trim(conf.Mqtt.Topic, "/"), site)
if err == nil {
go mqtt.Run(site, pipe.NewDropper(append(ignoreMqtt, ignoreEmpty)...).Pipe(tee.Attach()))
}
}
// announce on mDNS

View file

@ -69,7 +69,6 @@ var conf = globalConfig{
}
type globalConfig struct {
URI interface{} // TODO deprecated
Network networkConfig
Log string
SponsorToken string
@ -658,7 +657,8 @@ func configureLoadpoints(conf globalConfig) (loadpoints []*core.Loadpoint, err e
}
log := util.NewLoggerWithLoadpoint("lp-"+strconv.Itoa(id+1), id+1)
lp, err := core.NewLoadpointFromConfig(log, lpc)
settings := &core.Settings{Key: "lp" + strconv.Itoa(id+1) + "."}
lp, err := core.NewLoadpointFromConfig(log, settings, lpc)
if err != nil {
return nil, fmt.Errorf("failed configuring loadpoint: %w", err)
}

View file

@ -31,7 +31,7 @@ func TestYamlOff(t *testing.T) {
t.Error(err)
}
if lp.Mode != api.ModeOff {
t.Errorf("expected `off`, got %s", lp.Mode)
if lp.Mode_ != api.ModeOff {
t.Errorf("expected `off`, got %s", lp.Mode_)
}
}

View file

@ -1,31 +0,0 @@
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)
chargerIcon = "chargerIcon" // charger icon for ui
vehicleCapacity = "vehicleCapacity" // vehicle battery capacity
vehicleDetectionActive = "vehicleDetectionActive" // vehicle detection active
vehicleIcon = "vehicleIcon" // vehicle icon for ui
vehicleOdometer = "vehicleOdometer" // vehicle odometer
vehiclePresent = "vehiclePresent" // vehicle detected
vehicleRange = "vehicleRange" // vehicle range
vehicleSoc = "vehicleSoc" // vehicle soc
vehicleTargetSoc = "vehicleTargetSoc" // vehicle soc limit
vehicleTitle = "vehicleTitle" // vehicle title
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)
)

View file

@ -1,8 +1,6 @@
package coordinator
import (
"slices"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
)
@ -25,8 +23,8 @@ func (a *adapter) GetVehicles() []api.Vehicle {
return a.c.GetVehicles()
}
func (a *adapter) GetVehicleIndex(v api.Vehicle) int {
return slices.Index(a.c.vehicles, v)
func (a *adapter) Owner(v api.Vehicle) loadpoint.API {
return a.c.Owner(v)
}
func (a *adapter) Acquire(v api.Vehicle) {

View file

@ -1,12 +1,24 @@
package coordinator
import "github.com/evcc-io/evcc/api"
import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
)
// API is the coordinator API
type API interface {
// GetVehicles returns the list of all vehicles
GetVehicles() []api.Vehicle
// Owner returns the loadpoint that currently owns the vehicle
Owner(api.Vehicle) loadpoint.API
// Aquire acquires the vehicle for the loadpoint and releases it at any other loadpoint
Acquire(api.Vehicle)
// Release releases a vehicle from a loadpoint
Release(api.Vehicle)
// IdentifyVehicleByStatus returns an available vehicle that is currently connected or charging
IdentifyVehicleByStatus() api.Vehicle
GetVehicleIndex(api.Vehicle) int
}

View file

@ -1,6 +1,7 @@
package coordinator
import (
"slices"
"sync"
"github.com/evcc-io/evcc/api"
@ -10,7 +11,7 @@ import (
// Coordinator coordinates vehicle access between loadpoints
type Coordinator struct {
mu sync.Mutex
mu sync.RWMutex
log *util.Logger
vehicles []api.Vehicle
tracked map[api.Vehicle]loadpoint.API
@ -27,12 +28,25 @@ func New(log *util.Logger, vehicles []api.Vehicle) *Coordinator {
// GetVehicles returns the list of all vehicles
func (c *Coordinator) GetVehicles() []api.Vehicle {
c.mu.Lock()
defer c.mu.Unlock()
c.mu.RLock()
defer c.mu.RUnlock()
return c.vehicles
return slices.Clone(c.vehicles)
}
// Owner returns the loadpoint that currently owns the vehicle
func (c *Coordinator) Owner(vehicle api.Vehicle) loadpoint.API {
c.mu.RLock()
defer c.mu.RUnlock()
if owner, ok := c.tracked[vehicle]; ok {
return owner
}
return nil
}
// Add adds a vehicle to the coordinator
func (c *Coordinator) Add(vehicle api.Vehicle) {
c.mu.Lock()
defer c.mu.Unlock()
@ -40,6 +54,7 @@ func (c *Coordinator) Add(vehicle api.Vehicle) {
c.vehicles = append(c.vehicles, vehicle)
}
// Delete removes a vehicle from the coordinator
func (c *Coordinator) Delete(vehicle api.Vehicle) {
c.mu.Lock()
@ -90,8 +105,8 @@ func (c *Coordinator) release(vehicle api.Vehicle) {
func (c *Coordinator) availableDetectibleVehicles(owner loadpoint.API) []api.Vehicle {
var res []api.Vehicle
c.mu.Lock()
defer c.mu.Unlock()
c.mu.RLock()
defer c.mu.RUnlock()
for _, vv := range c.vehicles {
// status api available
@ -110,8 +125,8 @@ func (c *Coordinator) availableDetectibleVehicles(owner loadpoint.API) []api.Veh
func (c *Coordinator) identifyVehicleByStatus(available []api.Vehicle) api.Vehicle {
var res api.Vehicle
c.mu.Lock()
defer c.mu.Unlock()
c.mu.RLock()
defer c.mu.RUnlock()
for _, vehicle := range available {
if vs, ok := vehicle.(api.ChargeState); ok {

View file

@ -2,27 +2,28 @@ package coordinator
import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
)
type dummy struct{}
// NewDummy creates a dummy coordinator without vehicles
func NewDummy() API {
return &dummy{}
return new(dummy)
}
func (a *dummy) GetVehicles() []api.Vehicle {
return nil
}
func (a *dummy) Acquire(v api.Vehicle) {}
func (a *dummy) Owner(api.Vehicle) loadpoint.API {
return nil
}
func (a *dummy) Release(v api.Vehicle) {}
func (a *dummy) Acquire(api.Vehicle) {}
func (a *dummy) Release(api.Vehicle) {}
func (a *dummy) IdentifyVehicleByStatus() api.Vehicle {
return nil
}
func (a *dummy) GetVehicleIndex(v api.Vehicle) int {
return -1
}

74
core/keys/loadpoint.go Normal file
View file

@ -0,0 +1,74 @@
package keys
const (
// loadpoint settings
Title = "title" // loadpoint title
Mode = "mode" // charge mode
Priority = "priority" // priority
MinCurrent = "minCurrent" // min current
MaxCurrent = "maxCurrent" // max current
MinSoc = "minSoc" // min soc
LimitSoc = "limitSoc" // limit soc
LimitEnergy = "limitEnergy" // limit energy
EnableThreshold = "enableThreshold"
DisableThreshold = "disableThreshold"
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)
ChargerIcon = "chargerIcon" // charger icon for ui
ChargerFeature = "chargerFeature" // charger feature
// loadpoint status
Enabled = "enabled" // loadpoint enabled
Connected = "connected" // connected
Charging = "charging" // charging
// effective values
EffectivePriority = "effectivePriority" // effective priority
EffectivePlanTime = "effectivePlanTime" // effective plan time
EffectivePlanSoc = "effectivePlanSoc" // effective plan soc
EffectiveMinCurrent = "effectiveMinCurrent" // effective min current
EffectiveMaxCurrent = "effectiveMaxCurrent" // effective max current
EffectiveLimitSoc = "effectiveLimitSoc" // effective limit soc
// measurements
ChargeCurrent = "chargeCurrent" // charge current
ChargePower = "chargePower" // charge power
ChargeCurrents = "chargeCurrents" // charge currents
ChargeVoltages = "chargeVoltages" // charge voltages
ChargedEnergy = "chargedEnergy" // charged energy
ChargeDuration = "chargeDuration" // charge duration
ChargeTotalImport = "chargeTotalImport" // charge meter total import
// session
ConnectedDuration = "connectedDuration" // connected duration
ChargeRemainingDuration = "chargeRemainingDuration" // charge remaining duration
ChargeRemainingEnergy = "chargeRemainingEnergy" // charge remaining energy
// plan
PlanTime = "planTime" // charge plan finish time goal
PlanEnergy = "planEnergy" // charge plan energy goal
PlanSoc = "planSoc" // charge plan soc goal
PlanActive = "planActive" // charge plan has determined current slot to be an active slot
PlanProjectedStart = "planProjectedStart" // charge plan start time (earliest slot)
// remote control
RemoteDisabled = "remoteDisabled" // remote disabled
RemoteDisabledSource = "remoteDisabledSource" // remote disabled source
// vehicle
VehicleTitle = "vehicleTitle" // vehicle title
VehicleName = "vehicleName" // vehicle name
VehicleIdentity = "vehicleIdentity" // vehicle identity
VehicleCapacity = "vehicleCapacity" // vehicle battery capacity
VehicleDetectionActive = "vehicleDetectionActive" // vehicle detection active
VehicleIcon = "vehicleIcon" // vehicle icon for ui
VehicleOdometer = "vehicleOdometer" // vehicle odometer
VehiclePresent = "vehiclePresent" // vehicle detected
VehicleRange = "vehicleRange" // vehicle range
VehicleSoc = "vehicleSoc" // vehicle soc
VehicleTargetSoc = "vehicleTargetSoc" // vehicle soc limit
VehicleClimaterActive = "vehicleClimaterActive" // vehicle climater active
)

47
core/keys/site.go Normal file
View file

@ -0,0 +1,47 @@
package keys
const (
Aux = "aux"
AuxPower = "auxPower"
Currency = "currency"
GreenShareHome = "greenShareHome"
GreenShareLoadpoints = "greenShareLoadpoints"
GridConfigured = "gridConfigured"
GridCurrents = "gridCurrents"
GridEnergy = "gridEnergy"
GridPowers = "gridPowers"
HomePower = "homePower"
PrioritySoc = "prioritySoc"
Pv = "pv"
PvConfigured = "pvConfigured"
PvEnergy = "pvEnergy"
PvPower = "pvPower"
ResidualPower = "residualPower"
SiteTitle = "siteTitle"
SmartCostActive = "smartCostActive"
SmartCostLimit = "smartCostLimit"
SmartCostType = "smartCostType"
Statistics = "statistics"
TariffCo2 = "tariffCo2"
TariffCo2Home = "tariffCo2Home"
TariffCo2Loadpoints = "tariffCo2Loadpoin"
TariffFeedIn = "tariffFeedIn"
TariffGrid = "tariffGrid"
TariffPriceHome = "tariffPriceHome"
TariffPriceLoadpoints = "tariffPriceLoadpoints"
Vehicles = "vehicles"
// battery settings
BatteryCapacity = "batteryCapacity"
BatteryDischargeControl = "batteryDischargeControl"
BufferSoc = "bufferSoc"
BufferStartSoc = "bufferStartSoc"
// battery status
Battery = "battery"
BatteryConfigured = "batteryConfigured"
BatteryEnergy = "batteryEnergy"
BatteryMode = "batteryMode"
BatteryPower = "batteryPower"
BatterySoc = "batterySoc"
)

View file

@ -11,10 +11,12 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/coordinator"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/planner"
"github.com/evcc-io/evcc/core/session"
"github.com/evcc-io/evcc/core/soc"
"github.com/evcc-io/evcc/core/vehicle"
"github.com/evcc-io/evcc/core/wrapper"
"github.com/evcc-io/evcc/provider"
"github.com/evcc-io/evcc/push"
@ -25,7 +27,6 @@ import (
evbus "github.com/asaskevich/EventBus"
"github.com/avast/retry-go/v4"
"github.com/benbjohnson/clock"
"github.com/cjrd/allocate"
)
const (
@ -68,14 +69,10 @@ type PollConfig struct {
Interval time.Duration `mapstructure:"interval"` // interval when not charging
}
// SocConfig defines soc settings, estimation and update behaviour
// SocConfig defines soc settings, estimation and update behavior
type SocConfig struct {
Poll PollConfig `mapstructure:"poll"`
Estimate *bool `mapstructure:"estimate"`
Min_ int `mapstructure:"min"` // TODO deprecated
Target_ int `mapstructure:"target"` // TODO deprecated
min int // Default minimum Soc, guarded by mutex
target int // Default target Soc, guarded by mutex
}
// Poll modes
@ -107,27 +104,28 @@ type Loadpoint struct {
log *util.Logger
// exposed public configuration
sync.Mutex // guard status
vehicleMux sync.Mutex // guard vehicle
Mode api.ChargeMode `mapstructure:"mode"` // Charge mode, guarded by mutex
sync.RWMutex // guard status
Title_ string `mapstructure:"title"` // UI title
Priority_ int `mapstructure:"priority"` // Priority
ConfiguredPhases int `mapstructure:"phases"` // Charger configured phase mode 0/1/3
ChargerRef string `mapstructure:"charger"` // Charger reference
VehicleRef string `mapstructure:"vehicle"` // Vehicle reference
VehiclesRef_ []string `mapstructure:"vehicles"` // TODO deprecated
MeterRef string `mapstructure:"meter"` // Charge meter reference
Soc SocConfig
Enable, Disable ThresholdConfig
ResetOnDisconnect bool `mapstructure:"resetOnDisconnect"`
onDisconnect api.ActionConfig
targetEnergy float64 // Target charge energy for dumb vehicles in kWh
vehicleMux sync.Mutex // guard vehicle
Mode_ api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect
Title_ string `mapstructure:"title"` // UI title
Priority_ int `mapstructure:"priority"` // Priority
ConfiguredPhases int `mapstructure:"phases"` // Charger configured phase mode 0/1/3
ChargerRef string `mapstructure:"charger"` // Charger reference
VehicleRef string `mapstructure:"vehicle"` // Vehicle reference
MeterRef string `mapstructure:"meter"` // Charge meter reference
Soc SocConfig
Enable, Disable ThresholdConfig
MinCurrent float64 // PV mode: start current Min+PV mode: min current
MaxCurrent float64 // Max allowed current. Physically ensured by the charger
GuardDuration time.Duration // charger enable/disable minimum holding time
limitSoc int // Session limit for soc
limitEnergy float64 // Session limit for energy
mode api.ChargeMode
enabled bool // Charger enabled state
phases int // Charger enabled phases, guarded by mutex
measuredPhases int // Charger physically measured phases
@ -150,9 +148,10 @@ type Loadpoint struct {
coordinator coordinator.API
socEstimator *soc.Estimator
// target charging
// charge planning
planner *planner.Planner
targetTime time.Time // time goal
planTime time.Time // time goal
planEnergy float64 // Plan charge energy in kWh (dumb vehicles)
planSlotEnd time.Time // current plan slot end time
planActive bool // charge plan exists and has a currently active slot
@ -178,12 +177,14 @@ type Loadpoint struct {
db *session.DB
session *session.Session
settings *Settings
tasks *util.Queue[Task] // tasks to be executed
}
// NewLoadpointFromConfig creates a new loadpoint
func NewLoadpointFromConfig(log *util.Logger, other map[string]interface{}) (*Loadpoint, error) {
lp := NewLoadpoint(log)
func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[string]interface{}) (*Loadpoint, error) {
lp := NewLoadpoint(log, settings)
if err := util.DecodeOther(other, lp); err != nil {
return nil, err
}
@ -208,17 +209,6 @@ func NewLoadpointFromConfig(log *util.Logger, other map[string]interface{}) (*Lo
lp.log.WARN.Println("maxCurrent must be larger than minCurrent")
}
if lp.Soc.Min_ != 0 {
lp.log.WARN.Println("Configuring soc.min at loadpoint is deprecated and must be applied per vehicle")
}
if lp.Soc.Target_ != 0 {
lp.log.WARN.Println("Configuring soc.target at loadpoint is deprecated and must be applied per vehicle")
}
// store defaults
lp.collectDefaults()
if lp.MeterRef != "" {
dev, err := config.Meters().ByName(lp.MeterRef)
if err != nil {
@ -236,11 +226,6 @@ func NewLoadpointFromConfig(log *util.Logger, other map[string]interface{}) (*Lo
lp.defaultVehicle = dev.Instance()
}
// TODO deprecated
if len(lp.VehiclesRef_) > 0 {
lp.log.WARN.Println("vehicles option is deprecated")
}
if lp.ChargerRef == "" {
return nil, errors.New("missing charger")
}
@ -271,19 +256,28 @@ func NewLoadpointFromConfig(log *util.Logger, other map[string]interface{}) (*Lo
lp.log.WARN.Printf("PV mode enable threshold %.0fW > 0 will start PV charging on grid power consumption. Did you mean -%.0f?", lp.Enable.Threshold, lp.Enable.Threshold)
}
// choose sane default if mode is not set
if lp.mode = lp.Mode_; lp.mode == "" {
lp.mode = api.ModeOff
}
// restore settings
lp.restoreSettings()
return lp, nil
}
// NewLoadpoint creates a Loadpoint with sane defaults
func NewLoadpoint(log *util.Logger) *Loadpoint {
func NewLoadpoint(log *util.Logger, settings *Settings) *Loadpoint {
clock := clock.New()
bus := evbus.New()
lp := &Loadpoint{
log: log, // logger
clock: clock, // mockable time
bus: bus, // event bus
Mode: api.ModeOff,
log: log, // logger
settings: settings, // settings
clock: clock, // mockable time
bus: bus, // event bus
mode: api.ModeOff,
status: api.StatusNone,
MinCurrent: 6, // A
MaxCurrent: 16, // A
@ -292,8 +286,6 @@ func NewLoadpoint(log *util.Logger) *Loadpoint {
Interval: pollInterval,
Mode: pollCharging,
},
min: 0, // %
target: 100, // %
},
Enable: ThresholdConfig{Delay: time.Minute, Threshold: 0}, // t, W
Disable: ThresholdConfig{Delay: 3 * time.Minute, Threshold: 0}, // t, W
@ -307,24 +299,17 @@ func NewLoadpoint(log *util.Logger) *Loadpoint {
return lp
}
// collectDefaults collects default values for use on disconnect
func (lp *Loadpoint) collectDefaults() {
// get reference to action config
actionCfg := &lp.onDisconnect
// allocate action config such that all pointer fields are fully allocated
if err := allocate.Zero(actionCfg); err == nil {
// initialize with default values
*actionCfg.Mode = lp.GetMode()
*actionCfg.MinCurrent = lp.GetMinCurrent()
*actionCfg.MaxCurrent = lp.GetMaxCurrent()
*actionCfg.Priority = lp.GetPriority()
} else {
lp.log.ERROR.Printf("error allocating action config: %v", err)
// restoreSettings restores loadpoint settings
func (lp *Loadpoint) restoreSettings() {
if v, err := lp.settings.String(keys.Mode); err == nil {
lp.mode = api.ChargeMode(v)
}
if v, err := lp.settings.Time(keys.PlanTime); err == nil {
lp.planTime = v
}
if v, err := lp.settings.Float(keys.PlanEnergy); err == nil {
lp.planEnergy = v
}
// deprecated: do not reapply deprecated lp config values
actionCfg.TargetSoc = nil
actionCfg.MinSoc_ = nil
}
// requestUpdate requests site to update this loadpoint
@ -445,11 +430,11 @@ func (lp *Loadpoint) evVehicleConnectHandler() {
// energy
lp.sessionEnergy.Reset()
lp.sessionEnergy.Publish("session", lp)
lp.publish("chargedEnergy", lp.getChargedEnergy())
lp.publish(keys.ChargedEnergy, lp.getChargedEnergy())
// duration
lp.connectedTime = lp.clock.Now()
lp.publish("connectedDuration", time.Duration(0))
lp.publish(keys.ConnectedDuration, time.Duration(0))
// soc update reset
lp.socUpdated = time.Time{}
@ -483,8 +468,8 @@ func (lp *Loadpoint) evVehicleDisconnectHandler() {
// energy and duration
lp.sessionEnergy.Publish("session", lp)
lp.publish("chargedEnergy", lp.getChargedEnergy())
lp.publish("connectedDuration", lp.clock.Since(lp.connectedTime).Round(time.Second))
lp.publish(keys.ChargedEnergy, lp.getChargedEnergy())
lp.publish(keys.ConnectedDuration, lp.clock.Since(lp.connectedTime).Round(time.Second))
// forget startup energy offset
lp.chargedAtStartup = 0
@ -493,25 +478,18 @@ func (lp *Loadpoint) evVehicleDisconnectHandler() {
lp.setVehicleIdentifier("")
lp.stopVehicleDetection()
// set default mode on disconnect
lp.defaultMode()
// set default vehicle (may be nil)
lp.setActiveVehicle(lp.defaultVehicle)
// set defaults
if lp.ResetOnDisconnect {
lp.applyAction(lp.onDisconnect)
}
// override global defaults with default vehicle
if lp.defaultVehicle != nil {
lp.applyAction(lp.defaultVehicle.OnIdentified())
}
// soc update reset
lp.socUpdated = time.Time{}
// reset plan once charge goal is met
lp.setTargetTime(time.Time{})
lp.setPlanActive(false)
// reset session
lp.SetLimitSoc(0)
lp.SetLimitEnergy(0)
}
// evVehicleSocProgressHandler sends external start event
@ -526,7 +504,7 @@ func (lp *Loadpoint) evChargeCurrentHandler(current float64) {
if !lp.enabled {
current = 0
}
lp.publish("chargeCurrent", current)
lp.publish(keys.ChargeCurrent, current)
}
// evChargeCurrentWrappedMeterHandler updates the dummy charge meter's charge power.
@ -546,22 +524,14 @@ func (lp *Loadpoint) evChargeCurrentWrappedMeterHandler(current float64) {
lp.chargeMeter.(*wrapper.ChargeMeter).SetPower(power)
}
// applyAction executes the action
func (lp *Loadpoint) applyAction(actionCfg api.ActionConfig) {
if actionCfg.Mode != nil {
lp.SetMode(*actionCfg.Mode)
}
if min := actionCfg.MinCurrent; min != nil && *min >= *lp.onDisconnect.MinCurrent {
lp.SetMinCurrent(*min)
}
if actionCfg.TargetSoc != nil {
lp.SetTargetSoc(*actionCfg.TargetSoc)
}
if max := actionCfg.MaxCurrent; max != nil && *max <= *lp.onDisconnect.MaxCurrent {
lp.SetMaxCurrent(*max)
}
if actionCfg.Priority != nil {
lp.SetPriority(*actionCfg.Priority)
// defaultMode executes the action
func (lp *Loadpoint) defaultMode() {
lp.RLock()
mode := lp.Mode_
lp.RUnlock()
if mode != "" && mode != lp.GetMode() {
lp.SetMode(mode)
}
}
@ -580,16 +550,18 @@ 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(keys.Title, lp.Title())
lp.publish(keys.Mode, lp.GetMode())
lp.publish(keys.Priority, lp.GetPriority())
lp.publish(keys.MinCurrent, lp.GetMinCurrent())
lp.publish(keys.MaxCurrent, lp.GetMaxCurrent())
lp.publish("enableThreshold", lp.Enable.Threshold)
lp.publish("disableThreshold", lp.Disable.Threshold)
lp.publish(keys.EnableThreshold, lp.Enable.Threshold)
lp.publish(keys.DisableThreshold, lp.Disable.Threshold)
lp.setConfiguredPhases(lp.ConfiguredPhases)
lp.publish(phasesEnabled, lp.phases)
lp.publish(phasesActive, lp.activePhases())
lp.publish(keys.PhasesEnabled, lp.phases)
lp.publish(keys.PhasesActive, lp.activePhases())
lp.publishTimer(phaseTimer, 0, timerInactive)
lp.publishTimer(pvTimer, 0, timerInactive)
lp.publishTimer(guardTimer, 0, timerInactive)
@ -601,37 +573,39 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
// charger icon
if c, ok := lp.charger.(api.IconDescriber); ok {
lp.publish(chargerIcon, c.Icon())
lp.publish(keys.ChargerIcon, c.Icon())
} else {
lp.publish(chargerIcon, nil)
lp.publish(keys.ChargerIcon, nil)
}
// vehicle
lp.publish(vehiclePresent, false)
lp.publish(vehicleTitle, "")
lp.publish(vehicleIcon, "")
lp.publish(vehicleCapacity, 0.0)
lp.publish(vehicleOdometer, 0.0)
lp.publish(keys.VehiclePresent, false)
lp.publish(keys.VehicleTitle, "")
lp.publish(keys.VehicleIcon, "")
lp.publish(keys.VehicleName, "")
lp.publish(keys.VehicleCapacity, 0.0)
lp.publish(keys.VehicleOdometer, 0.0)
// assign and publish default vehicle
if lp.defaultVehicle != nil {
lp.setActiveVehicle(lp.defaultVehicle)
}
lp.publish("mode", lp.GetMode())
lp.publish("priority", lp.GetPriority())
lp.publish(targetSoc, lp.GetTargetSoc())
lp.publish(minSoc, lp.GetMinSoc())
// reset detection state
lp.publish(vehicleDetectionActive, false)
lp.publish(keys.VehicleDetectionActive, false)
// restored settings
lp.publish(keys.PlanTime, lp.planTime)
lp.publish(keys.PlanEnergy, lp.planEnergy)
lp.publish(keys.LimitSoc, lp.limitSoc)
lp.publish(keys.LimitEnergy, lp.limitEnergy)
// read initial charger state to prevent immediately disabling charger
if enabled, err := lp.charger.Enabled(); err == nil {
if lp.enabled = enabled; enabled {
lp.guardUpdated = lp.clock.Now()
// set defined current for use by pv mode
_ = lp.setLimit(lp.GetMinCurrent(), false)
_ = lp.setLimit(lp.effectiveMinCurrent(), false)
}
} else {
lp.log.ERROR.Printf("charger: %v", err)
@ -653,7 +627,7 @@ func (lp *Loadpoint) syncCharger() error {
if lp.guardGracePeriodElapsed() {
defer func() {
lp.enabled = enabled
lp.publish("enabled", lp.enabled)
lp.publish(keys.Enabled, lp.enabled)
}()
}
@ -713,7 +687,7 @@ func (lp *Loadpoint) setLimit(chargeCurrent float64, force bool) error {
}
// set current
if chargeCurrent != lp.chargeCurrent && chargeCurrent >= lp.GetMinCurrent() {
if chargeCurrent != lp.chargeCurrent && chargeCurrent >= lp.effectiveMinCurrent() {
var err error
if charger, ok := lp.charger.(api.ChargerEx); ok {
err = charger.MaxCurrentMillis(chargeCurrent)
@ -741,7 +715,7 @@ func (lp *Loadpoint) setLimit(chargeCurrent float64, force bool) error {
}
// set enabled/disabled
if enabled := chargeCurrent >= lp.GetMinCurrent(); enabled != lp.enabled {
if enabled := chargeCurrent >= lp.effectiveMinCurrent(); enabled != lp.enabled {
if remaining := (lp.GuardDuration - lp.clock.Since(lp.guardUpdated)).Truncate(time.Second); remaining > 0 && !force {
lp.publishTimer(guardTimer, lp.GuardDuration, guardEnable)
return nil
@ -764,7 +738,7 @@ func (lp *Loadpoint) setLimit(chargeCurrent float64, force bool) error {
lp.log.DEBUG.Printf("charger %s", status[enabled])
lp.enabled = enabled
lp.publish("enabled", lp.enabled)
lp.publish(keys.Enabled, lp.enabled)
lp.guardUpdated = lp.clock.Now()
lp.bus.Publish(evChargeCurrent, chargeCurrent)
@ -798,51 +772,58 @@ func (lp *Loadpoint) setStatus(status api.ChargeStatus) {
lp.status = status
}
// remainingChargeEnergy returns missing energy amount in kWh if vehicle has a valid energy target
func (lp *Loadpoint) remainingChargeEnergy() (float64, bool) {
return max(0, lp.targetEnergy-lp.getChargedEnergy()/1e3),
!lp.vehicleHasSoc() && lp.targetEnergy > 0
// socBasedPlanning returns true if vehicle soc (optionally from charger) and capacity are available
func (lp *Loadpoint) socBasedPlanning() bool {
v := lp.GetVehicle()
return (v != nil && v.Capacity() > 0) && (lp.vehicleHasSoc() || lp.vehicleSoc > 0)
}
// vehicleHasSoc returns true if active vehicle supports returning soc, i.e. it is not an offline vehicle
func (lp *Loadpoint) vehicleHasSoc() bool {
return lp.GetVehicle() != nil && !lp.vehicleHasFeature(api.Offline)
}
// targetEnergyReached checks if target is configured and reached
func (lp *Loadpoint) targetEnergyReached() bool {
f, ok := lp.remainingChargeEnergy()
// remainingLimitEnergy returns missing energy amount in kWh if vehicle has a valid energy target
func (lp *Loadpoint) remainingLimitEnergy() (float64, bool) {
limit := lp.GetLimitEnergy()
return max(0, limit-lp.getChargedEnergy()/1e3),
limit > 0 && !lp.socBasedPlanning()
}
// limitEnergyReached checks if target is configured and reached
func (lp *Loadpoint) limitEnergyReached() bool {
f, ok := lp.remainingLimitEnergy()
return ok && f <= 0
}
// targetSocReached checks if target is configured and reached.
// If vehicle is not configured this will always return false unless the
// charger is capable of and has provided an soc value.
func (lp *Loadpoint) targetSocReached() bool {
if _, ok := lp.charger.(api.Battery); lp.GetVehicle() == nil && !ok {
return false
}
return lp.Soc.target > 0 &&
lp.Soc.target < 100 &&
lp.vehicleSoc >= float64(lp.Soc.target)
// limitSocReached returns true if the effective limit has been reached
func (lp *Loadpoint) limitSocReached() bool {
limit := lp.effectiveLimitSoc()
return limit > 0 && lp.vehicleSoc >= float64(limit)
}
// minSocNotReached checks if minimum is configured and not reached.
// If vehicle is not configured this will always return false
func (lp *Loadpoint) minSocNotReached() bool {
vehicle := lp.GetVehicle()
if vehicle == nil || lp.Soc.min == 0 {
v := lp.GetVehicle()
if v == nil {
return false
}
minSoc := vehicle.Settings(lp.log, v).GetMinSoc()
if minSoc == 0 {
return false
}
if lp.vehicleSoc != 0 {
if lp.vehicleSoc < float64(lp.Soc.min) {
lp.log.DEBUG.Printf("forced charging at vehicle soc %.0f%% (< %.0f%% min soc)", lp.vehicleSoc, float64(lp.Soc.min))
active := lp.vehicleSoc < float64(minSoc)
if active {
lp.log.DEBUG.Printf("forced charging at vehicle soc %.0f%% (< %.0f%% min soc)", lp.vehicleSoc, float64(minSoc))
}
return lp.vehicleSoc < float64(lp.Soc.min)
return active
}
minEnergy := vehicle.Capacity() * float64(lp.Soc.min) / 100 / soc.ChargeEfficiency
minEnergy := v.Capacity() * float64(minSoc) / 100 / soc.ChargeEfficiency
return minEnergy > 0 && lp.getChargedEnergy() < minEnergy
}
@ -850,7 +831,7 @@ func (lp *Loadpoint) minSocNotReached() bool {
func (lp *Loadpoint) disableUnlessClimater() error {
var current float64 // zero disables
if lp.vehicleClimateActive() {
current = lp.GetMinCurrent()
current = lp.effectiveMinCurrent()
}
// reset plan once charge goal is met
@ -1031,7 +1012,7 @@ func (lp *Loadpoint) scalePhases(phases int) error {
func (lp *Loadpoint) fastCharging() error {
err := lp.scalePhasesIfAvailable(3)
if err == nil {
err = lp.setLimit(lp.GetMaxCurrent(), true)
err = lp.setLimit(lp.effectiveMaxCurrent(), true)
}
return err
}
@ -1147,8 +1128,8 @@ func (lp *Loadpoint) publishTimer(name string, delay time.Duration, action strin
// pvMaxCurrent calculates the maximum target current for PV mode
func (lp *Loadpoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64, batteryBuffered, batteryStart bool) float64 {
// read only once to simplify testing
minCurrent := lp.GetMinCurrent()
maxCurrent := lp.GetMaxCurrent()
minCurrent := lp.effectiveMinCurrent()
maxCurrent := lp.effectiveMaxCurrent()
// switch phases up/down
if _, ok := lp.charger.(api.PhaseSwitcher); ok {
@ -1253,7 +1234,7 @@ func (lp *Loadpoint) UpdateChargePower() {
lp.Unlock()
lp.log.DEBUG.Printf("charge power: %.0fW", value)
lp.publish("chargePower", value)
lp.publish(keys.ChargePower, value)
// https://github.com/evcc-io/evcc/issues/2153
// https://github.com/evcc-io/evcc/issues/6986
@ -1285,7 +1266,7 @@ func (lp *Loadpoint) updateChargeCurrents() {
lp.chargeCurrents = []float64{i1, i2, i3}
lp.log.DEBUG.Printf("charge currents: %.3gA", lp.chargeCurrents)
lp.publish("chargeCurrents", lp.chargeCurrents)
lp.publish(keys.ChargeCurrents, lp.chargeCurrents)
if lp.charging() && lp.phaseSwitchCompleted() {
var phases int
@ -1301,7 +1282,7 @@ func (lp *Loadpoint) updateChargeCurrents() {
lp.Unlock()
lp.log.DEBUG.Printf("detected active phases: %dp", phases)
lp.publish(phasesActive, phases)
lp.publish(keys.PhasesActive, phases)
}
}
}
@ -1325,7 +1306,7 @@ func (lp *Loadpoint) updateChargeVoltages() {
chargeVoltages := []float64{u1, u2, u3}
lp.log.DEBUG.Printf("charge voltages: %.3gV", chargeVoltages)
lp.publish("chargeVoltages", chargeVoltages)
lp.publish(keys.ChargeVoltages, chargeVoltages)
// Quine-McCluskey for (¬L1∧L2∧¬L3) ∨ (L1∧L2∧¬L3) ∨ (¬L1∧¬L2∧L3) ∨ (L1∧¬L2∧L3) ∨ (¬L1∧L2∧L3) -> ¬L1 ∧ L3 ∨ L2 ∧ ¬L3 ∨ ¬L2 ∧ L3
if !(u1 >= minActiveVoltage) && (u3 >= minActiveVoltage) || (u2 >= minActiveVoltage) && !(u3 >= minActiveVoltage) || !(u2 >= minActiveVoltage) && (u3 >= minActiveVoltage) {
@ -1367,12 +1348,14 @@ func (lp *Loadpoint) publishChargeProgress() {
lp.log.ERROR.Printf("charge timer: %v", err)
}
// TODO check if "session" prefix required?
lp.sessionEnergy.Publish("session", lp)
// deprecated: use sessionEnergy instead
lp.publish("chargedEnergy", lp.getChargedEnergy())
lp.publish("chargeDuration", lp.chargeDuration)
// TODO deprecated: use sessionEnergy instead
lp.publish(keys.ChargedEnergy, lp.getChargedEnergy())
lp.publish(keys.ChargeDuration, lp.chargeDuration)
if _, ok := lp.chargeMeter.(api.MeterEnergy); ok {
lp.publish("chargeTotalImport", lp.chargeMeterTotal())
lp.publish(keys.ChargeTotalImport, lp.chargeMeterTotal())
}
}
@ -1386,7 +1369,7 @@ func (lp *Loadpoint) publishSocAndRange() {
// We need to check if the charger can provide soc and use it if available.
if err == nil {
lp.vehicleSoc = soc
lp.publish(vehicleSoc, lp.vehicleSoc)
lp.publish(keys.VehicleSoc, lp.vehicleSoc)
}
return
@ -1408,39 +1391,37 @@ func (lp *Loadpoint) publishSocAndRange() {
lp.vehicleSoc = f
lp.log.DEBUG.Printf("vehicle soc: %.0f%%", lp.vehicleSoc)
lp.publish(vehicleSoc, lp.vehicleSoc)
lp.publish(keys.VehicleSoc, lp.vehicleSoc)
// vehicle target soc
// TODO take vehicle api limits into account
targetSoc := 100
if vs, ok := lp.GetVehicle().(api.SocLimiter); ok {
if limit, err := vs.TargetSoc(); err == nil {
targetSoc = int(math.Trunc(limit))
lp.log.DEBUG.Printf("vehicle soc limit: %.0f%%", limit)
lp.publish(vehicleTargetSoc, limit)
lp.publish(keys.VehicleTargetSoc, limit)
} else {
lp.log.ERROR.Printf("vehicle soc limit: %v", err)
}
}
// use minimum of vehicle and loadpoint
socLimit := targetSoc
if lp.Soc.target < socLimit {
socLimit = lp.Soc.target
}
limitSoc := min(targetSoc, lp.effectiveLimitSoc())
var d time.Duration
if lp.charging() {
d = lp.socEstimator.RemainingChargeDuration(socLimit, lp.chargePower)
d = lp.socEstimator.RemainingChargeDuration(limitSoc, lp.chargePower)
}
lp.SetRemainingDuration(d)
lp.SetRemainingEnergy(1e3 * lp.socEstimator.RemainingChargeEnergy(socLimit))
lp.SetRemainingEnergy(1e3 * lp.socEstimator.RemainingChargeEnergy(limitSoc))
// range
if vs, ok := lp.GetVehicle().(api.VehicleRange); ok {
if rng, err := vs.Range(); err == nil {
lp.log.DEBUG.Printf("vehicle range: %dkm", rng)
lp.publish(vehicleRange, rng)
lp.publish(keys.VehicleRange, rng)
} else {
lp.log.ERROR.Printf("vehicle range: %v", err)
}
@ -1525,6 +1506,7 @@ func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered, batt
// update progress and soc before status is updated
lp.publishChargeProgress()
lp.PublishEffectiveValues()
// read and publish status
if err := lp.updateChargerStatus(); err != nil {
@ -1532,8 +1514,8 @@ func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered, batt
return
}
lp.publish("connected", lp.connected())
lp.publish("charging", lp.charging())
lp.publish(keys.Connected, lp.connected())
lp.publish(keys.Charging, lp.charging())
// identify connected vehicle
if lp.connected() && !lp.chargerHasFeature(api.IntegratedDevice) {
@ -1563,7 +1545,7 @@ func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered, batt
remoteDisabled := loadpoint.RemoteEnable
mode := lp.GetMode()
lp.publish("mode", mode)
lp.publish(keys.Mode, mode)
// update and publish plan without being short-circuited by modes etc.
plannerActive := lp.plannerActive()
@ -1578,12 +1560,12 @@ func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered, batt
case lp.scalePhasesRequired():
err = lp.scalePhases(lp.ConfiguredPhases)
case lp.targetEnergyReached():
lp.log.DEBUG.Printf("targetEnergy reached: %.0fkWh > %0.1fkWh", lp.getChargedEnergy()/1e3, lp.targetEnergy)
case lp.limitEnergyReached():
lp.log.DEBUG.Printf("limitEnergy reached: %.0fkWh > %0.1fkWh", lp.getChargedEnergy()/1e3, lp.limitEnergy)
err = lp.disableUnlessClimater()
case lp.targetSocReached():
lp.log.DEBUG.Printf("targetSoc reached: %.1f%% > %d%%", lp.vehicleSoc, lp.Soc.target)
case lp.limitSocReached():
lp.log.DEBUG.Printf("limitSoc reached: %.1f%% > %d%%", lp.vehicleSoc, lp.effectiveLimitSoc())
err = lp.disableUnlessClimater()
case lp.remoteControlled(loadpoint.RemoteHardDisable):
@ -1605,7 +1587,7 @@ func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered, batt
case mode == api.ModeMinPV || mode == api.ModePV:
// cheap tariff
if autoCharge && lp.GetTargetTime().IsZero() {
if autoCharge && lp.EffectivePlanTime().IsZero() {
err = lp.fastCharging()
lp.resetPhaseTimer()
lp.elapsePVTimer() // let PV mode disable immediately afterwards
@ -1616,7 +1598,7 @@ func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered, batt
var required bool // false
if targetCurrent == 0 && lp.vehicleClimateActive() {
targetCurrent = lp.GetMinCurrent()
targetCurrent = lp.effectiveMinCurrent()
required = true
}
@ -1632,13 +1614,14 @@ func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered, batt
// Wake-up checks
if lp.enabled && lp.status == api.StatusB &&
int(lp.vehicleSoc) < lp.Soc.target && lp.wakeUpTimer.Expired() {
// TODO take vehicle api limits into account
int(lp.vehicleSoc) < lp.effectiveLimitSoc() && lp.wakeUpTimer.Expired() {
lp.wakeUpVehicle()
}
// effective disabled status
if remoteDisabled != loadpoint.RemoteEnable {
lp.publish("remoteDisabled", remoteDisabled)
lp.publish(keys.RemoteDisabled, remoteDisabled)
}
// log any error

View file

@ -15,9 +15,6 @@ type Controller interface {
// API is the external loadpoint API
type API interface {
// Title returns the defined loadpoint title
Title() string
//
// status
//
@ -29,38 +26,65 @@ type API interface {
// settings
//
// GetPriority returns the loadpoint priority
// Title returns the defined loadpoint title
Title() string
// GetPriority returns the priority
GetPriority() int
// SetPriority sets the loadpoint priority
// SetPriority sets the priority
SetPriority(int)
// GetMinCurrent returns the min charging current
GetMinCurrent() float64
// SetMinCurrent sets the min charging current
SetMinCurrent(float64)
// GetMaxCurrent returns the max charging current
GetMaxCurrent() float64
// SetMaxCurrent sets the max charging current
SetMaxCurrent(float64)
// GetMode returns the charge mode
GetMode() api.ChargeMode
// SetMode sets the charge mode
SetMode(api.ChargeMode)
// GetMinSoc returns the charge minimum soc
GetMinSoc() int
// SetMinSoc sets the charge minimum soc
SetMinSoc(int)
// GetPhases returns the enabled phases
GetPhases() int
// SetPhases sets the enabled phases
SetPhases(int) error
// GetTargetTime returns the target time
GetTargetTime() time.Time
// SetTargetTime sets the target time
SetTargetTime(time.Time) error
// GetTargetEnergy returns the charge target energy
GetTargetEnergy() float64
// SetTargetEnergy sets the charge target energy
SetTargetEnergy(float64)
// GetTargetSoc returns the charge target soc
GetTargetSoc() int
// SetTargetSoc sets the charge target soc
SetTargetSoc(int)
// GetLimitSoc returns the session limit soc
GetLimitSoc() int
// SetLimitSoc sets the session limit soc
SetLimitSoc(soc int)
// GetLimitEnergy returns the session limit energy
GetLimitEnergy() float64
// SetLimitEnergy sets the session limit energy
SetLimitEnergy(energy float64)
//
// effective values
//
// EffectivePriority returns the effective priority
EffectivePriority() int
// EffectivePlanTime returns the effective plan time
EffectivePlanTime() time.Time
// EffectiveMinPower returns the min charging power for a single phase
EffectiveMinPower() float64
// EffectiveMaxPower returns the max charging power taking active phases into account
EffectiveMaxPower() float64
// PublishEffectiveValues publishes effective values for currently attached vehicle
PublishEffectiveValues()
//
// plan
//
// GetPlanEnergy returns the charge plan energy
GetPlanEnergy() (time.Time, float64)
// SetPlanEnergy sets the charge plan energy
SetPlanEnergy(time.Time, float64) error
// GetPlan creates a charging plan
GetPlan(targetTime time.Time, maxPower float64) (time.Duration, api.Rates, error)
// GetEnableThreshold gets the loadpoint enable threshold
GetEnableThreshold() float64
// SetEnableThreshold sets loadpoint enable threshold
@ -83,18 +107,6 @@ type API interface {
GetChargePower() float64
// GetChargePowerFlexibility returns the flexible amount of current charging power
GetChargePowerFlexibility() float64
// GetMinCurrent returns the min charging current
GetMinCurrent() float64
// SetMinCurrent sets the min charging current
SetMinCurrent(float64)
// GetMaxCurrent returns the max charging current
GetMaxCurrent() float64
// SetMaxCurrent sets the max charging current
SetMaxCurrent(float64)
// GetMinPower returns the min charging power for a single phase
GetMinPower() float64
// GetMaxPower returns the max charging power taking active phases into account
GetMaxPower() float64
//
// charge progress

View file

@ -35,6 +35,62 @@ func (m *MockAPI) EXPECT() *MockAPIMockRecorder {
return m.recorder
}
// EffectiveMaxPower mocks base method.
func (m *MockAPI) EffectiveMaxPower() float64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "EffectiveMaxPower")
ret0, _ := ret[0].(float64)
return ret0
}
// EffectiveMaxPower indicates an expected call of EffectiveMaxPower.
func (mr *MockAPIMockRecorder) EffectiveMaxPower() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectiveMaxPower", reflect.TypeOf((*MockAPI)(nil).EffectiveMaxPower))
}
// EffectiveMinPower mocks base method.
func (m *MockAPI) EffectiveMinPower() float64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "EffectiveMinPower")
ret0, _ := ret[0].(float64)
return ret0
}
// EffectiveMinPower indicates an expected call of EffectiveMinPower.
func (mr *MockAPIMockRecorder) EffectiveMinPower() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectiveMinPower", reflect.TypeOf((*MockAPI)(nil).EffectiveMinPower))
}
// EffectivePlanTime mocks base method.
func (m *MockAPI) EffectivePlanTime() time.Time {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "EffectivePlanTime")
ret0, _ := ret[0].(time.Time)
return ret0
}
// EffectivePlanTime indicates an expected call of EffectivePlanTime.
func (mr *MockAPIMockRecorder) EffectivePlanTime() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePlanTime", reflect.TypeOf((*MockAPI)(nil).EffectivePlanTime))
}
// EffectivePriority mocks base method.
func (m *MockAPI) EffectivePriority() int {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "EffectivePriority")
ret0, _ := ret[0].(int)
return ret0
}
// EffectivePriority indicates an expected call of EffectivePriority.
func (mr *MockAPIMockRecorder) EffectivePriority() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePriority", reflect.TypeOf((*MockAPI)(nil).EffectivePriority))
}
// GetChargePower mocks base method.
func (m *MockAPI) GetChargePower() float64 {
m.ctrl.T.Helper()
@ -91,6 +147,34 @@ func (mr *MockAPIMockRecorder) GetEnableThreshold() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEnableThreshold", reflect.TypeOf((*MockAPI)(nil).GetEnableThreshold))
}
// GetLimitEnergy mocks base method.
func (m *MockAPI) GetLimitEnergy() float64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetLimitEnergy")
ret0, _ := ret[0].(float64)
return ret0
}
// GetLimitEnergy indicates an expected call of GetLimitEnergy.
func (mr *MockAPIMockRecorder) GetLimitEnergy() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLimitEnergy", reflect.TypeOf((*MockAPI)(nil).GetLimitEnergy))
}
// GetLimitSoc mocks base method.
func (m *MockAPI) GetLimitSoc() int {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetLimitSoc")
ret0, _ := ret[0].(int)
return ret0
}
// GetLimitSoc indicates an expected call of GetLimitSoc.
func (mr *MockAPIMockRecorder) GetLimitSoc() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLimitSoc", reflect.TypeOf((*MockAPI)(nil).GetLimitSoc))
}
// GetMaxCurrent mocks base method.
func (m *MockAPI) GetMaxCurrent() float64 {
m.ctrl.T.Helper()
@ -105,20 +189,6 @@ func (mr *MockAPIMockRecorder) GetMaxCurrent() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxCurrent", reflect.TypeOf((*MockAPI)(nil).GetMaxCurrent))
}
// GetMaxPower mocks base method.
func (m *MockAPI) GetMaxPower() float64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetMaxPower")
ret0, _ := ret[0].(float64)
return ret0
}
// GetMaxPower indicates an expected call of GetMaxPower.
func (mr *MockAPIMockRecorder) GetMaxPower() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxPower", reflect.TypeOf((*MockAPI)(nil).GetMaxPower))
}
// GetMinCurrent mocks base method.
func (m *MockAPI) GetMinCurrent() float64 {
m.ctrl.T.Helper()
@ -133,34 +203,6 @@ func (mr *MockAPIMockRecorder) GetMinCurrent() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMinCurrent", reflect.TypeOf((*MockAPI)(nil).GetMinCurrent))
}
// GetMinPower mocks base method.
func (m *MockAPI) GetMinPower() float64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetMinPower")
ret0, _ := ret[0].(float64)
return ret0
}
// GetMinPower indicates an expected call of GetMinPower.
func (mr *MockAPIMockRecorder) GetMinPower() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMinPower", reflect.TypeOf((*MockAPI)(nil).GetMinPower))
}
// GetMinSoc mocks base method.
func (m *MockAPI) GetMinSoc() int {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetMinSoc")
ret0, _ := ret[0].(int)
return ret0
}
// GetMinSoc indicates an expected call of GetMinSoc.
func (mr *MockAPIMockRecorder) GetMinSoc() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMinSoc", reflect.TypeOf((*MockAPI)(nil).GetMinSoc))
}
// GetMode mocks base method.
func (m *MockAPI) GetMode() api.ChargeMode {
m.ctrl.T.Helper()
@ -219,6 +261,21 @@ func (mr *MockAPIMockRecorder) GetPlanActive() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanActive", reflect.TypeOf((*MockAPI)(nil).GetPlanActive))
}
// GetPlanEnergy mocks base method.
func (m *MockAPI) GetPlanEnergy() (time.Time, float64) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPlanEnergy")
ret0, _ := ret[0].(time.Time)
ret1, _ := ret[1].(float64)
return ret0, ret1
}
// GetPlanEnergy indicates an expected call of GetPlanEnergy.
func (mr *MockAPIMockRecorder) GetPlanEnergy() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanEnergy", reflect.TypeOf((*MockAPI)(nil).GetPlanEnergy))
}
// GetPriority mocks base method.
func (m *MockAPI) GetPriority() int {
m.ctrl.T.Helper()
@ -275,48 +332,6 @@ func (mr *MockAPIMockRecorder) GetStatus() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStatus", reflect.TypeOf((*MockAPI)(nil).GetStatus))
}
// GetTargetEnergy mocks base method.
func (m *MockAPI) GetTargetEnergy() float64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTargetEnergy")
ret0, _ := ret[0].(float64)
return ret0
}
// GetTargetEnergy indicates an expected call of GetTargetEnergy.
func (mr *MockAPIMockRecorder) GetTargetEnergy() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTargetEnergy", reflect.TypeOf((*MockAPI)(nil).GetTargetEnergy))
}
// GetTargetSoc mocks base method.
func (m *MockAPI) GetTargetSoc() int {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTargetSoc")
ret0, _ := ret[0].(int)
return ret0
}
// GetTargetSoc indicates an expected call of GetTargetSoc.
func (mr *MockAPIMockRecorder) GetTargetSoc() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTargetSoc", reflect.TypeOf((*MockAPI)(nil).GetTargetSoc))
}
// GetTargetTime mocks base method.
func (m *MockAPI) GetTargetTime() time.Time {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTargetTime")
ret0, _ := ret[0].(time.Time)
return ret0
}
// GetTargetTime indicates an expected call of GetTargetTime.
func (mr *MockAPIMockRecorder) GetTargetTime() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTargetTime", reflect.TypeOf((*MockAPI)(nil).GetTargetTime))
}
// GetVehicle mocks base method.
func (m *MockAPI) GetVehicle() api.Vehicle {
m.ctrl.T.Helper()
@ -345,6 +360,18 @@ func (mr *MockAPIMockRecorder) HasChargeMeter() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasChargeMeter", reflect.TypeOf((*MockAPI)(nil).HasChargeMeter))
}
// PublishEffectiveValues mocks base method.
func (m *MockAPI) PublishEffectiveValues() {
m.ctrl.T.Helper()
m.ctrl.Call(m, "PublishEffectiveValues")
}
// PublishEffectiveValues indicates an expected call of PublishEffectiveValues.
func (mr *MockAPIMockRecorder) PublishEffectiveValues() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishEffectiveValues", reflect.TypeOf((*MockAPI)(nil).PublishEffectiveValues))
}
// RemoteControl mocks base method.
func (m *MockAPI) RemoteControl(arg0 string, arg1 RemoteDemand) {
m.ctrl.T.Helper()
@ -381,6 +408,30 @@ func (mr *MockAPIMockRecorder) SetEnableThreshold(arg0 interface{}) *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetEnableThreshold", reflect.TypeOf((*MockAPI)(nil).SetEnableThreshold), arg0)
}
// SetLimitEnergy mocks base method.
func (m *MockAPI) SetLimitEnergy(arg0 float64) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SetLimitEnergy", arg0)
}
// SetLimitEnergy indicates an expected call of SetLimitEnergy.
func (mr *MockAPIMockRecorder) SetLimitEnergy(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLimitEnergy", reflect.TypeOf((*MockAPI)(nil).SetLimitEnergy), arg0)
}
// SetLimitSoc mocks base method.
func (m *MockAPI) SetLimitSoc(arg0 int) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SetLimitSoc", arg0)
}
// SetLimitSoc indicates an expected call of SetLimitSoc.
func (mr *MockAPIMockRecorder) SetLimitSoc(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLimitSoc", reflect.TypeOf((*MockAPI)(nil).SetLimitSoc), arg0)
}
// SetMaxCurrent mocks base method.
func (m *MockAPI) SetMaxCurrent(arg0 float64) {
m.ctrl.T.Helper()
@ -405,18 +456,6 @@ func (mr *MockAPIMockRecorder) SetMinCurrent(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetMinCurrent", reflect.TypeOf((*MockAPI)(nil).SetMinCurrent), arg0)
}
// SetMinSoc mocks base method.
func (m *MockAPI) SetMinSoc(arg0 int) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SetMinSoc", arg0)
}
// SetMinSoc indicates an expected call of SetMinSoc.
func (mr *MockAPIMockRecorder) SetMinSoc(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetMinSoc", reflect.TypeOf((*MockAPI)(nil).SetMinSoc), arg0)
}
// SetMode mocks base method.
func (m *MockAPI) SetMode(arg0 api.ChargeMode) {
m.ctrl.T.Helper()
@ -443,6 +482,20 @@ func (mr *MockAPIMockRecorder) SetPhases(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPhases", reflect.TypeOf((*MockAPI)(nil).SetPhases), arg0)
}
// SetPlanEnergy mocks base method.
func (m *MockAPI) SetPlanEnergy(arg0 time.Time, arg1 float64) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetPlanEnergy", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// SetPlanEnergy indicates an expected call of SetPlanEnergy.
func (mr *MockAPIMockRecorder) SetPlanEnergy(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanEnergy", reflect.TypeOf((*MockAPI)(nil).SetPlanEnergy), arg0, arg1)
}
// SetPriority mocks base method.
func (m *MockAPI) SetPriority(arg0 int) {
m.ctrl.T.Helper()
@ -455,44 +508,6 @@ func (mr *MockAPIMockRecorder) SetPriority(arg0 interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPriority", reflect.TypeOf((*MockAPI)(nil).SetPriority), arg0)
}
// SetTargetEnergy mocks base method.
func (m *MockAPI) SetTargetEnergy(arg0 float64) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SetTargetEnergy", arg0)
}
// SetTargetEnergy indicates an expected call of SetTargetEnergy.
func (mr *MockAPIMockRecorder) SetTargetEnergy(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTargetEnergy", reflect.TypeOf((*MockAPI)(nil).SetTargetEnergy), arg0)
}
// SetTargetSoc mocks base method.
func (m *MockAPI) SetTargetSoc(arg0 int) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SetTargetSoc", arg0)
}
// SetTargetSoc indicates an expected call of SetTargetSoc.
func (mr *MockAPIMockRecorder) SetTargetSoc(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTargetSoc", reflect.TypeOf((*MockAPI)(nil).SetTargetSoc), arg0)
}
// SetTargetTime mocks base method.
func (m *MockAPI) SetTargetTime(arg0 time.Time) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetTargetTime", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// SetTargetTime indicates an expected call of SetTargetTime.
func (mr *MockAPIMockRecorder) SetTargetTime(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTargetTime", reflect.TypeOf((*MockAPI)(nil).SetTargetTime), arg0)
}
// SetVehicle mocks base method.
func (m *MockAPI) SetVehicle(arg0 api.Vehicle) {
m.ctrl.T.Helper()

View file

@ -6,6 +6,7 @@ import (
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/wrapper"
)
@ -14,21 +15,23 @@ var _ loadpoint.API = (*Loadpoint)(nil)
// Title returns the human-readable loadpoint title
func (lp *Loadpoint) Title() string {
lp.RLock()
defer lp.RUnlock()
return lp.Title_
}
// GetStatus returns the charging status
func (lp *Loadpoint) GetStatus() api.ChargeStatus {
lp.Lock()
defer lp.Unlock()
lp.RLock()
defer lp.RUnlock()
return lp.status
}
// GetMode returns loadpoint charge mode
func (lp *Loadpoint) GetMode() api.ChargeMode {
lp.Lock()
defer lp.Unlock()
return lp.Mode
lp.RLock()
defer lp.RUnlock()
return lp.mode
}
// SetMode sets loadpoint charge mode
@ -44,9 +47,10 @@ func (lp *Loadpoint) SetMode(mode api.ChargeMode) {
lp.log.DEBUG.Printf("set charge mode: %s", string(mode))
// apply immediately
if lp.Mode != mode {
lp.Mode = mode
lp.publish("mode", mode)
if lp.mode != mode {
lp.mode = mode
lp.publish(keys.Mode, mode)
lp.settings.SetString(keys.Mode, string(mode))
// reset timers
switch mode {
@ -62,45 +66,17 @@ func (lp *Loadpoint) SetMode(mode api.ChargeMode) {
}
}
// getChargedEnergy returns loadpoint charge target energy in Wh
// getChargedEnergy returns session charge energy in Wh
func (lp *Loadpoint) getChargedEnergy() float64 {
lp.Lock()
defer lp.Unlock()
lp.RLock()
defer lp.RUnlock()
return lp.sessionEnergy.TotalWh()
}
// GetTargetEnergy returns loadpoint charge target energy
func (lp *Loadpoint) GetTargetEnergy() float64 {
lp.Lock()
defer lp.Unlock()
return lp.targetEnergy
}
// setTargetEnergy sets loadpoint charge target energy (no mutex)
func (lp *Loadpoint) setTargetEnergy(energy float64) {
lp.targetEnergy = energy
lp.publish(targetEnergy, energy)
}
// SetTargetEnergy sets loadpoint charge target energy
func (lp *Loadpoint) SetTargetEnergy(energy float64) {
lp.Lock()
defer lp.Unlock()
lp.log.DEBUG.Println("set target energy:", energy)
// apply immediately
if lp.targetEnergy != energy {
lp.setTargetEnergy(energy)
lp.requestUpdate()
lp.persistVehicleSettings()
}
}
// GetPriority returns the loadpoint priority
func (lp *Loadpoint) GetPriority() int {
lp.Lock()
defer lp.Unlock()
lp.RLock()
defer lp.RUnlock()
return lp.Priority_
}
@ -113,70 +89,14 @@ func (lp *Loadpoint) SetPriority(prio int) {
if lp.Priority_ != prio {
lp.Priority_ = prio
lp.publish("priority", prio)
}
}
// GetTargetSoc returns loadpoint charge target soc
func (lp *Loadpoint) GetTargetSoc() int {
lp.Lock()
defer lp.Unlock()
return lp.Soc.target
}
// setTargetSoc sets loadpoint charge target soc (no mutex)
func (lp *Loadpoint) setTargetSoc(soc int) {
lp.Soc.target = soc
lp.publish(targetSoc, soc)
}
// SetTargetSoc sets loadpoint charge target soc
func (lp *Loadpoint) SetTargetSoc(soc int) {
lp.Lock()
defer lp.Unlock()
lp.log.DEBUG.Println("set target soc:", soc)
// apply immediately
if lp.Soc.target != soc {
lp.setTargetSoc(soc)
lp.requestUpdate()
lp.persistVehicleSettings()
}
}
// GetMinSoc returns loadpoint charge minimum soc
func (lp *Loadpoint) GetMinSoc() int {
lp.Lock()
defer lp.Unlock()
return lp.Soc.min
}
// setMinSoc sets loadpoint charge min soc (no mutex)
func (lp *Loadpoint) setMinSoc(soc int) {
lp.Soc.min = soc
lp.publish(minSoc, soc)
}
// SetMinSoc sets loadpoint charge minimum soc
func (lp *Loadpoint) SetMinSoc(soc int) {
lp.Lock()
defer lp.Unlock()
lp.log.DEBUG.Println("set min soc:", soc)
// apply immediately
if lp.Soc.min != soc {
lp.setMinSoc(soc)
lp.requestUpdate()
lp.persistVehicleSettings()
lp.publish(keys.Priority, prio)
}
}
// GetPhases returns loadpoint enabled phases
func (lp *Loadpoint) GetPhases() int {
lp.Lock()
defer lp.Unlock()
lp.RLock()
defer lp.RUnlock()
return lp.phases
}
@ -205,45 +125,110 @@ func (lp *Loadpoint) SetPhases(phases int) error {
return nil
}
// GetTargetTime returns the target time
func (lp *Loadpoint) GetTargetTime() time.Time {
lp.Lock()
defer lp.Unlock()
return lp.targetTime
// GetLimitSoc returns the session limit soc
func (lp *Loadpoint) GetLimitSoc() int {
lp.RLock()
defer lp.RUnlock()
return lp.limitSoc
}
// SetTargetTime sets the charge target time
func (lp *Loadpoint) SetTargetTime(finishAt time.Time) error {
if !finishAt.IsZero() && finishAt.Before(time.Now()) {
return errors.New("timestamp is in the past")
// setLimitSoc sets the session limit soc (no mutex)
func (lp *Loadpoint) setLimitSoc(soc int) {
lp.limitSoc = soc
lp.publish(keys.LimitSoc, soc)
}
// SetLimitSoc sets the session soc limit
func (lp *Loadpoint) SetLimitSoc(soc int) {
lp.Lock()
defer lp.Unlock()
lp.log.DEBUG.Println("set session soc limit:", soc)
// apply immediately
if lp.limitSoc != soc {
lp.setLimitSoc(soc)
lp.requestUpdate()
}
}
// GetLimitEnergy returns the session limit energy
func (lp *Loadpoint) GetLimitEnergy() float64 {
lp.RLock()
defer lp.RUnlock()
return lp.limitEnergy
}
// setLimitEnergy sets the session limit energy (no mutex)
func (lp *Loadpoint) setLimitEnergy(energy float64) {
lp.limitEnergy = energy
lp.publish(keys.LimitEnergy, energy)
}
// SetLimitEnergy sets the session energy limit
func (lp *Loadpoint) SetLimitEnergy(energy float64) {
lp.Lock()
defer lp.Unlock()
lp.log.DEBUG.Println("set session energy limit:", energy)
// apply immediately
if lp.limitEnergy != energy {
lp.setLimitEnergy(energy)
lp.requestUpdate()
}
}
// GetPlanEnergy returns plan target energy
func (lp *Loadpoint) GetPlanEnergy() (time.Time, float64) {
lp.RLock()
defer lp.RUnlock()
return lp.planTime, lp.planEnergy
}
// setPlanEnergy sets plan target energy (no mutex)
func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, energy float64) {
lp.planEnergy = energy
lp.publish(keys.PlanEnergy, energy)
lp.settings.SetFloat(keys.PlanEnergy, energy)
if energy == 0 {
lp.setPlanActive(false)
}
lp.Lock()
defer lp.Unlock()
lp.planTime = finishAt
lp.publish(keys.PlanTime, finishAt)
lp.settings.SetTime(keys.PlanTime, finishAt)
lp.log.DEBUG.Println("set target time:", finishAt.Round(time.Second).Local())
lp.setTargetTime(finishAt)
lp.persistVehicleSettings()
return nil
}
// setTargetTime sets the charge target time
func (lp *Loadpoint) setTargetTime(finishAt time.Time) {
lp.targetTime = finishAt
lp.publish(targetTime, finishAt)
// TODO planActive is not guarded by mutex
if finishAt.IsZero() {
lp.setPlanActive(false)
}
}
// GetEnableThreshold gets the loadpoint enable threshold
func (lp *Loadpoint) GetEnableThreshold() float64 {
// SetPlanEnergy sets plan target energy
func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, energy float64) error {
lp.Lock()
defer lp.Unlock()
if !finishAt.IsZero() && finishAt.Before(time.Now()) {
return errors.New("timestamp is in the past")
}
lp.log.DEBUG.Printf("set plan energy: %.3gkWh @ %v", energy, finishAt.Round(time.Second).Local())
// apply immediately
if lp.planEnergy != energy || !lp.planTime.Equal(finishAt) {
lp.setPlanEnergy(finishAt, energy)
lp.requestUpdate()
}
return nil
}
// GetEnableThreshold gets the loadpoint enable threshold
func (lp *Loadpoint) GetEnableThreshold() float64 {
lp.RLock()
defer lp.RUnlock()
return lp.Enable.Threshold
}
@ -256,14 +241,14 @@ func (lp *Loadpoint) SetEnableThreshold(threshold float64) {
if lp.Enable.Threshold != threshold {
lp.Enable.Threshold = threshold
lp.publish("enableThreshold", threshold)
lp.publish(keys.EnableThreshold, threshold)
}
}
// GetDisableThreshold gets the loadpoint enable threshold
func (lp *Loadpoint) GetDisableThreshold() float64 {
lp.Lock()
defer lp.Unlock()
lp.RLock()
defer lp.RUnlock()
return lp.Disable.Threshold
}
@ -276,7 +261,7 @@ func (lp *Loadpoint) SetDisableThreshold(threshold float64) {
if lp.Disable.Threshold != threshold {
lp.Disable.Threshold = threshold
lp.publish("disableThreshold", threshold)
lp.publish(keys.DisableThreshold, threshold)
}
}
@ -291,8 +276,8 @@ func (lp *Loadpoint) RemoteControl(source string, demand loadpoint.RemoteDemand)
if lp.remoteDemand != demand {
lp.remoteDemand = demand
lp.publish("remoteDisabled", demand)
lp.publish("remoteDisabledSource", source)
lp.publish(keys.RemoteDisabled, demand)
lp.publish(keys.RemoteDisabledSource, source)
lp.requestUpdate()
}
@ -306,8 +291,8 @@ func (lp *Loadpoint) HasChargeMeter() bool {
// GetChargePower returns the current charge power
func (lp *Loadpoint) GetChargePower() float64 {
lp.Lock()
defer lp.Unlock()
lp.RLock()
defer lp.RUnlock()
return lp.chargePower
}
@ -324,13 +309,13 @@ func (lp *Loadpoint) GetChargePowerFlexibility() float64 {
}
// MinPV mode
return max(0, lp.GetChargePower()-lp.GetMinPower())
return max(0, lp.GetChargePower()-lp.EffectiveMinPower())
}
// GetMinCurrent returns the min loadpoint current
func (lp *Loadpoint) GetMinCurrent() float64 {
lp.Lock()
defer lp.Unlock()
lp.RLock()
defer lp.RUnlock()
return lp.MinCurrent
}
@ -343,14 +328,14 @@ func (lp *Loadpoint) SetMinCurrent(current float64) {
if current != lp.MinCurrent {
lp.MinCurrent = current
lp.publish(minCurrent, lp.MinCurrent)
lp.publish(keys.MinCurrent, lp.MinCurrent)
}
}
// GetMaxCurrent returns the max loadpoint current
func (lp *Loadpoint) GetMaxCurrent() float64 {
lp.Lock()
defer lp.Unlock()
lp.RLock()
defer lp.RUnlock()
return lp.MaxCurrent
}
@ -363,18 +348,18 @@ func (lp *Loadpoint) SetMaxCurrent(current float64) {
if current != lp.MaxCurrent {
lp.MaxCurrent = current
lp.publish(maxCurrent, lp.MaxCurrent)
lp.publish(keys.MaxCurrent, lp.MaxCurrent)
}
}
// GetMinPower returns the min loadpoint power for a single phase
func (lp *Loadpoint) GetMinPower() float64 {
return Voltage * lp.GetMinCurrent()
return Voltage * lp.effectiveMinCurrent()
}
// GetMaxPower returns the max loadpoint power taking vehicle capabilities and phase scaling into account
func (lp *Loadpoint) GetMaxPower() float64 {
return Voltage * lp.GetMaxCurrent() * float64(lp.maxActivePhases())
return Voltage * lp.effectiveMaxCurrent() * float64(lp.maxActivePhases())
}
// GetPlanActive returns the active state of the planner
@ -384,6 +369,13 @@ func (lp *Loadpoint) GetPlanActive() bool {
return lp.planActive
}
// GetRemainingDuration is the estimated remaining charging duration
func (lp *Loadpoint) GetRemainingDuration() time.Duration {
lp.Lock()
defer lp.Unlock()
return lp.chargeRemainingDuration
}
// SetRemainingDuration sets the estimated remaining charging duration
func (lp *Loadpoint) SetRemainingDuration(chargeRemainingDuration time.Duration) {
lp.Lock()
@ -395,15 +387,15 @@ func (lp *Loadpoint) SetRemainingDuration(chargeRemainingDuration time.Duration)
func (lp *Loadpoint) setRemainingDuration(remainingDuration time.Duration) {
if lp.chargeRemainingDuration != remainingDuration {
lp.chargeRemainingDuration = remainingDuration
lp.publish(chargeRemainingDuration, remainingDuration)
lp.publish(keys.ChargeRemainingDuration, remainingDuration)
}
}
// GetRemainingDuration is the estimated remaining charging duration
func (lp *Loadpoint) GetRemainingDuration() time.Duration {
lp.Lock()
defer lp.Unlock()
return lp.chargeRemainingDuration
// GetRemainingEnergy is the remaining charge energy in Wh
func (lp *Loadpoint) GetRemainingEnergy() float64 {
lp.RLock()
defer lp.RUnlock()
return lp.chargeRemainingEnergy
}
// SetRemainingEnergy sets the remaining charge energy in Wh
@ -417,17 +409,10 @@ func (lp *Loadpoint) SetRemainingEnergy(chargeRemainingEnergy float64) {
func (lp *Loadpoint) setRemainingEnergy(chargeRemainingEnergy float64) {
if lp.chargeRemainingEnergy != chargeRemainingEnergy {
lp.chargeRemainingEnergy = chargeRemainingEnergy
lp.publish("chargeRemainingEnergy", chargeRemainingEnergy)
lp.publish(keys.ChargeRemainingEnergy, chargeRemainingEnergy)
}
}
// GetRemainingEnergy is the remaining charge energy in Wh
func (lp *Loadpoint) GetRemainingEnergy() float64 {
lp.Lock()
defer lp.Unlock()
return lp.chargeRemainingEnergy
}
// GetVehicle gets the active vehicle
func (lp *Loadpoint) GetVehicle() api.Vehicle {
lp.vehicleMux.Lock()

View file

@ -4,6 +4,7 @@ import (
"slices"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/soc"
)
@ -22,7 +23,7 @@ func (lp *Loadpoint) publishChargerFeature(f api.Feature) {
if ok {
ok = slices.Contains(c.Features(), f)
}
lp.publish("chargerFeature"+f.String(), ok)
lp.publish(keys.ChargerFeature+f.String(), ok)
}
// chargerSoc returns charger soc if available

120
core/loadpoint_effective.go Normal file
View file

@ -0,0 +1,120 @@
package core
import (
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/vehicle"
)
// PublishEffectiveValues publishes all effective values
func (lp *Loadpoint) PublishEffectiveValues() {
lp.publish(keys.EffectivePriority, lp.EffectivePriority())
lp.publish(keys.EffectivePlanTime, lp.EffectivePlanTime())
lp.publish(keys.EffectivePlanSoc, lp.EffectivePlanSoc())
lp.publish(keys.EffectiveMinCurrent, lp.effectiveMinCurrent())
lp.publish(keys.EffectiveMaxCurrent, lp.effectiveMaxCurrent())
lp.publish(keys.EffectiveLimitSoc, lp.effectiveLimitSoc())
}
// EffectivePriority returns the effective priority
func (lp *Loadpoint) EffectivePriority() int {
if v := lp.GetVehicle(); v != nil {
if res, ok := v.OnIdentified().GetPriority(); ok {
return res
}
}
return lp.GetPriority()
}
// vehiclePlanSoc returns the next vehicle plan time and soc
func (lp *Loadpoint) vehiclePlanSoc() (time.Time, int) {
if v := lp.GetVehicle(); v != nil {
return vehicle.Settings(lp.log, v).GetPlanSoc()
}
return time.Time{}, 0
}
// EffectivePlanSoc returns the soc target for the current plan
func (lp *Loadpoint) EffectivePlanSoc() int {
_, soc := lp.vehiclePlanSoc()
return soc
}
// EffectivePlanTime returns the effective plan time
func (lp *Loadpoint) EffectivePlanTime() time.Time {
if lp.socBasedPlanning() {
ts, _ := lp.vehiclePlanSoc()
return ts
}
ts, _ := lp.GetPlanEnergy()
return ts
}
// effectiveMinCurrent returns the effective min current
func (lp *Loadpoint) effectiveMinCurrent() float64 {
if v := lp.GetVehicle(); v != nil {
if res, ok := v.OnIdentified().GetMinCurrent(); ok {
return res
}
}
if c, ok := lp.charger.(api.CurrentLimiter); ok {
if res, _, err := c.GetMinMaxCurrent(); err == nil {
lp.publish(keys.EffectiveMinCurrent, res)
return res
}
}
return lp.GetMinCurrent()
}
// effectiveMaxCurrent returns the effective max current
func (lp *Loadpoint) effectiveMaxCurrent() float64 {
if v := lp.GetVehicle(); v != nil {
if res, ok := v.OnIdentified().GetMaxCurrent(); ok {
return res
}
}
if c, ok := lp.charger.(api.CurrentLimiter); ok {
if _, res, err := c.GetMinMaxCurrent(); err == nil {
lp.publish(keys.EffectiveMaxCurrent, res)
return res
}
}
return lp.GetMaxCurrent()
}
// effectiveLimitSoc returns the effective session limit soc
// TODO take vehicle api limits into account
func (lp *Loadpoint) effectiveLimitSoc() int {
lp.RLock()
defer lp.RUnlock()
if lp.limitSoc > 0 {
return lp.limitSoc
}
if v := lp.GetVehicle(); v != nil {
if soc := vehicle.Settings(lp.log, v).GetLimitSoc(); soc > 0 {
return soc
}
}
return 100
}
// EffectiveMinPower returns the effective min power for a single phase
func (lp *Loadpoint) EffectiveMinPower() float64 {
// TODO check if 1p available
return Voltage * lp.effectiveMinCurrent()
}
// EffectiveMaxPower returns the effective max power taking vehicle capabilities and phase scaling into account
func (lp *Loadpoint) EffectiveMaxPower() float64 {
return Voltage * lp.effectiveMaxCurrent() * float64(lp.maxActivePhases())
}

View file

@ -2,6 +2,7 @@ package core
import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
)
// setConfiguredPhases sets the default phase configuration
@ -13,9 +14,9 @@ func (lp *Loadpoint) setConfiguredPhases(phases int) {
// publish 1p3p capability and phase configuration
if _, ok := lp.charger.(api.PhaseSwitcher); ok {
lp.publish(phasesConfigured, lp.ConfiguredPhases)
lp.publish(keys.PhasesConfigured, lp.ConfiguredPhases)
} else {
lp.publish(phasesConfigured, nil)
lp.publish(keys.PhasesConfigured, nil)
}
}
@ -27,7 +28,7 @@ func (lp *Loadpoint) setPhases(phases int) {
lp.Unlock()
// publish updated phase configuration
lp.publish(phasesEnabled, lp.phases)
lp.publish(keys.PhasesEnabled, lp.phases)
// reset timer to disabled state
lp.resetPhaseTimer()
@ -43,7 +44,7 @@ func (lp *Loadpoint) resetMeasuredPhases() {
lp.measuredPhases = 0
lp.Unlock()
lp.publish(phasesActive, lp.activePhases())
lp.publish(keys.PhasesActive, lp.activePhases())
}
// getMeasuredPhases provides synchronized access to measuredPhases

View file

@ -173,6 +173,7 @@ func TestPvScalePhases(t *testing.T) {
vehicle := api.NewMockVehicle(ctrl)
vehicle.EXPECT().Phases().Return(tc.vehicle).MinTimes(1)
vehicle.EXPECT().OnIdentified().Return(api.ActionConfig{}).AnyTimes()
lp := &Loadpoint{
log: util.NewLogger("foo"),
@ -183,7 +184,7 @@ func TestPvScalePhases(t *testing.T) {
chargeTimer: &Null{}, // silence nil panics
progress: NewProgress(0, 10), // silence nil panics
wakeUpTimer: NewTimer(), // silence nil panics
Mode: api.ModeNow,
mode: api.ModeNow,
MinCurrent: minA,
MaxCurrent: maxA,
vehicle: vehicle,

View file

@ -5,7 +5,9 @@ import (
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/planner"
"github.com/evcc-io/evcc/core/vehicle"
)
const (
@ -13,32 +15,40 @@ const (
smallGapDuration = 60 * time.Minute // small gap duration between planner slots we might ignore
)
// TODO planActive is not guarded by mutex
// setPlanActive updates plan active flag
func (lp *Loadpoint) setPlanActive(active bool) {
if !active {
lp.planSlotEnd = time.Time{}
}
lp.planActive = active
lp.publish(planActive, lp.planActive)
if lp.planActive != active {
lp.planActive = active
lp.publish(keys.PlanActive, lp.planActive)
}
}
// remainingPlanEnergy returns missing energy amount in kWh
func (lp *Loadpoint) remainingPlanEnergy() (float64, bool) {
_, limit := lp.GetPlanEnergy()
return max(0, limit-lp.getChargedEnergy()/1e3),
limit > 0 && !lp.socBasedPlanning()
}
// planRequiredDuration is the estimated total charging duration
func (lp *Loadpoint) planRequiredDuration(maxPower float64) time.Duration {
if energy, ok := lp.remainingChargeEnergy(); ok {
if energy, ok := lp.remainingPlanEnergy(); ok {
return time.Duration(energy * 1e3 / maxPower * float64(time.Hour))
}
if lp.socEstimator == nil {
v := lp.GetVehicle()
if v == nil || lp.socEstimator == nil {
return 0
}
// TODO vehicle soc limit
targetSoc := lp.Soc.target
if targetSoc == 0 {
targetSoc = 100
}
_, soc := vehicle.Settings(lp.log, v).GetPlanSoc()
return lp.socEstimator.RemainingChargeDuration(targetSoc, maxPower)
return lp.socEstimator.RemainingChargeDuration(soc, maxPower)
}
// GetPlan creates a charging plan
@ -73,11 +83,12 @@ func (lp *Loadpoint) plannerActive() (active bool) {
var planStart time.Time
defer func() {
lp.publish(planProjectedStart, planStart)
lp.publish(keys.PlanProjectedStart, planStart)
}()
maxPower := lp.GetMaxPower()
requiredDuration, plan, err := lp.GetPlan(lp.GetTargetTime(), maxPower)
maxPower := lp.EffectiveMaxPower()
requiredDuration, plan, err := lp.GetPlan(lp.EffectivePlanTime(), maxPower)
if err != nil {
lp.log.ERROR.Println("planner:", err)
return false
@ -95,7 +106,7 @@ func (lp *Loadpoint) plannerActive() (active bool) {
planStart = planner.Start(plan)
lp.log.DEBUG.Printf("plan: charge %v%s starting at %v until %v (power: %.0fW, avg cost: %.3f)",
planner.Duration(plan).Round(time.Second), requiredString, planStart.Round(time.Second).Local(), lp.targetTime.Round(time.Second).Local(),
planner.Duration(plan).Round(time.Second), requiredString, planStart.Round(time.Second).Local(), lp.planTime.Round(time.Second).Local(),
maxPower, planner.AverageCost(plan))
// log plan
@ -119,7 +130,7 @@ func (lp *Loadpoint) plannerActive() (active bool) {
} else if lp.planActive {
// planner was active (any slot, not necessarily previous slot) and charge goal has not yet been met
switch {
case lp.clock.Now().After(lp.targetTime) && !lp.targetTime.IsZero():
case lp.clock.Now().After(lp.planTime) && !lp.planTime.IsZero():
// if the plan did not (entirely) work, we may still be charging beyond plan end- in that case, continue charging
// TODO check when schedule is implemented
lp.log.DEBUG.Println("plan: continuing after target time")

View file

@ -11,7 +11,6 @@ import (
"github.com/evcc-io/evcc/push"
"github.com/evcc-io/evcc/util"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
)
const (
@ -80,7 +79,7 @@ func attachListeners(t *testing.T, lp *Loadpoint) {
}
func TestNew(t *testing.T) {
lp := NewLoadpoint(util.NewLogger("foo"))
lp := NewLoadpoint(util.NewLogger("foo"), nil)
if lp.phases != 0 {
t.Errorf("Phases %v", lp.phases)
@ -180,7 +179,7 @@ func TestUpdatePowerZero(t *testing.T) {
tc.expect(charger)
}
lp.Mode = tc.mode
lp.mode = tc.mode
lp.Update(0, false, false, false, 0, nil, nil) // false,sitePower false,0
ctrl.Finish()
@ -386,8 +385,11 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
vehicle := api.NewMockVehicle(ctrl)
// wrap vehicle with estimator
vehicle.EXPECT().Capacity().Return(float64(10))
vehicle.EXPECT().Capacity().Return(float64(10)).AnyTimes()
vehicle.EXPECT().Phases().Return(0).AnyTimes()
vehicle.EXPECT().Title().Return("foo").AnyTimes() // TODO remove when settings always available for vehicle
vehicle.EXPECT().OnIdentified().Return(api.ActionConfig{}).AnyTimes()
socEstimator := soc.NewEstimator(util.NewLogger("foo"), charger, vehicle, false)
lp := &Loadpoint{
@ -400,15 +402,15 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
chargeTimer: &Null{}, // silence nil panics
progress: NewProgress(0, 10), // silence nil panics
wakeUpTimer: NewTimer(), // silence nil panics
// coordinator: coordinator.NewDummy(), // 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,
mode: api.ModeNow,
sessionEnergy: NewEnergyMetrics(),
limitSoc: 90, // session limit
Soc: SocConfig{
target: 90,
Poll: PollConfig{
Mode: pollConnected, // allow polling when connected
Interval: pollInterval,
@ -479,19 +481,14 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
MinCurrent: minA,
MaxCurrent: maxA,
status: api.StatusC,
Mode: api.ModeOff,
Soc: SocConfig{
target: 70,
},
ResetOnDisconnect: true,
Mode_: api.ModeOff, // default mode
}
attachListeners(t, lp)
lp.collectDefaults()
lp.enabled = true
lp.chargeCurrent = float64(minA)
lp.Mode = api.ModeNow
lp.mode = api.ModeNow
t.Log("charging at min")
charger.EXPECT().Enabled().Return(lp.enabled, nil)
@ -506,8 +503,8 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
charger.EXPECT().Enable(false).Return(nil)
lp.Update(-3000, false, false, false, 0, nil, nil)
if lp.Mode != api.ModeOff {
t.Error("unexpected mode", lp.Mode)
if mode := lp.GetMode(); mode != api.ModeOff {
t.Error("unexpected mode", mode)
}
ctrl.Finish()
@ -558,7 +555,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
lp.enabled = true
lp.chargeCurrent = float64(maxA)
lp.Mode = api.ModeNow
lp.mode = api.ModeNow
// attach cache for verifying values
_, expectCache := cacheExpecter(t, lp)
@ -612,45 +609,97 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
ctrl.Finish()
}
func TestTargetSoc(t *testing.T) {
ctrl := gomock.NewController(t)
vhc := api.NewMockVehicle(ctrl)
// func TestTargetSoc(t *testing.T) {
// ctrl := gomock.NewController(t)
// vhc := api.NewMockVehicle(ctrl)
tc := []struct {
vehicle api.Vehicle
target int
soc float64
res bool
}{
{nil, 0, 0, false}, // never reached without vehicle
{nil, 0, 10, false}, // never reached without vehicle
{nil, 80, 0, false}, // never reached without vehicle
{nil, 80, 80, false}, // never reached without vehicle
{nil, 80, 100, false}, // never reached without vehicle
{vhc, 0, 0, false}, // target disabled
{vhc, 0, 10, false}, // target disabled
{vhc, 80, 0, false}, // target not reached
{vhc, 80, 80, true}, // target reached
{vhc, 80, 100, true}, // target reached
{vhc, 100, 100, false}, // target reached, let ev control deactivation
}
// // TODO make vehicle settings mockable
// // make vehicle settings discoverable
// config.Vehicles().Add(config.NewStaticDevice[api.Vehicle](config.Named{}, vhc))
for _, tc := range tc {
t.Logf("%+v", tc)
// tc := []struct {
// vehicle api.Vehicle
// limitSoc int
// vehicleSoc float64
// res bool
// }{
// {nil, 0, 0, false}, // never reached without vehicle
// {nil, 0, 10, false}, // never reached without vehicle
// {nil, 80, 0, false}, // never reached without vehicle
// {nil, 80, 80, false}, // never reached without vehicle
// {nil, 80, 100, false}, // never reached without vehicle
// {vhc, 0, 0, false}, // target disabled
// {vhc, 0, 10, false}, // target disabled
// {vhc, 80, 0, false}, // target not reached
// {vhc, 80, 80, true}, // target reached
// {vhc, 80, 100, true}, // target reached
// {vhc, 100, 100, false}, // target reached, let ev control deactivation
// }
lp := &Loadpoint{
vehicle: tc.vehicle,
Soc: SocConfig{
target: tc.target,
},
vehicleSoc: tc.soc,
}
// for _, tc := range tc {
// t.Logf("%+v", tc)
if res := lp.targetSocReached(); tc.res != res {
t.Errorf("expected %v, got %v", tc.res, res)
}
}
}
// lp := &Loadpoint{
// vehicle: tc.vehicle,
// limitSoc: tc.limitSoc,
// vehicleSoc: tc.vehicleSoc,
// }
// if res := lp.limitSocReached(); tc.res != res {
// t.Errorf("expected %v, got %v", tc.res, res)
// }
// }
// }
// func TestMinSoc(t *testing.T) {
// ctrl := gomock.NewController(t)
// vhc := api.NewMockVehicle(ctrl)
// // TODO make vehicle settings mockable
// // make vehicle settings discoverable
// config.Vehicles().Add(config.NewStaticDevice[api.Vehicle](config.Named{}, vhc))
// tc := []struct {
// vehicle *api.MockVehicle
// min int
// soc float64
// energy float64
// res bool
// }{
// {nil, 0, 0, 0, false}, // never reached without vehicle
// {nil, 0, 10, 0, false}, // never reached without vehicle
// {nil, 80, 0, 0, false}, // never reached without vehicle
// {nil, 80, 80, 0, false}, // never reached without vehicle
// {nil, 80, 100, 0, false}, // never reached without vehicle
// {vhc, 0, 0, 0, false}, // min disabled
// {vhc, 0, 10, 0, false}, // min disabled
// {vhc, 80, 0, 0, true}, // min not reached
// {vhc, 80, 10, 0, true}, // min not reached
// {vhc, 80, 0, 8.0, true}, // min energy not reached
// {vhc, 80, 0, 9.0, false}, // min energy reached
// {vhc, 80, 80, 0, false}, // min reached
// {vhc, 80, 100, 0, false}, // min reached
// }
// for _, tc := range tc {
// lp := &Loadpoint{
// log: util.NewLogger("foo"),
// Soc: SocConfig{
// min: tc.min,
// },
// vehicleSoc: tc.soc,
// sessionEnergy: NewEnergyMetrics(),
// }
// lp.sessionEnergy.Update(tc.energy / 1e3)
// if v := tc.vehicle; v != nil {
// lp.vehicle = tc.vehicle // avoid assigning nil to interface
// v.EXPECT().Capacity().Return(10.0).MaxTimes(1)
// }
// assert.Equal(t, tc.res, lp.minSocNotReached(), tc)
// }
// }
func TestSocPoll(t *testing.T) {
clock := clock.NewMock()
@ -738,49 +787,3 @@ func TestSocPoll(t *testing.T) {
}
}
}
func TestMinSoc(t *testing.T) {
ctrl := gomock.NewController(t)
vhc := api.NewMockVehicle(ctrl)
tc := []struct {
vehicle *api.MockVehicle
min int
soc float64
energy float64
res bool
}{
{nil, 0, 0, 0, false}, // never reached without vehicle
{nil, 0, 10, 0, false}, // never reached without vehicle
{nil, 80, 0, 0, false}, // never reached without vehicle
{nil, 80, 80, 0, false}, // never reached without vehicle
{nil, 80, 100, 0, false}, // never reached without vehicle
{vhc, 0, 0, 0, false}, // min disabled
{vhc, 0, 10, 0, false}, // min disabled
{vhc, 80, 0, 0, true}, // min not reached
{vhc, 80, 10, 0, true}, // min not reached
{vhc, 80, 0, 8.0, true}, // min energy not reached
{vhc, 80, 0, 9.0, false}, // min energy reached
{vhc, 80, 80, 0, false}, // min reached
{vhc, 80, 100, 0, false}, // min reached
}
for _, tc := range tc {
lp := &Loadpoint{
log: util.NewLogger("foo"),
Soc: SocConfig{
min: tc.min,
},
vehicleSoc: tc.soc,
sessionEnergy: NewEnergyMetrics(),
}
lp.sessionEnergy.Update(tc.energy / 1e3)
if v := tc.vehicle; v != nil {
lp.vehicle = tc.vehicle // avoid assigning nil to interface
v.EXPECT().Capacity().Return(10.0).MaxTimes(1)
}
assert.Equal(t, tc.res, lp.minSocNotReached(), tc)
}
}

View file

@ -2,17 +2,17 @@ package core
import (
"errors"
"fmt"
"regexp"
"slices"
"strings"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/session"
"github.com/evcc-io/evcc/core/soc"
"github.com/evcc-io/evcc/core/vehicle"
"github.com/evcc-io/evcc/provider"
"github.com/evcc-io/evcc/server/db/settings"
)
const (
@ -32,7 +32,7 @@ func (lp *Loadpoint) coordinatedVehicles() []api.Vehicle {
func (lp *Loadpoint) setVehicleIdentifier(id string) {
if lp.vehicleIdentifier != id {
lp.vehicleIdentifier = id
lp.publish("vehicleIdentity", id)
lp.publish(keys.VehicleIdentity, id)
}
}
@ -100,12 +100,8 @@ func (lp *Loadpoint) selectVehicleByID(id string) api.Vehicle {
// setActiveVehicle assigns currently active vehicle, configures soc estimator
// and adds an odometer task
func (lp *Loadpoint) setActiveVehicle(vehicle api.Vehicle) {
func (lp *Loadpoint) setActiveVehicle(v api.Vehicle) {
lp.vehicleMux.Lock()
if lp.vehicle == vehicle {
lp.vehicleMux.Unlock()
return
}
from := "unknown"
if lp.vehicle != nil {
@ -113,30 +109,25 @@ func (lp *Loadpoint) setActiveVehicle(vehicle api.Vehicle) {
from = lp.vehicle.Title()
}
to := "unknown"
if vehicle != nil {
lp.coordinator.Acquire(vehicle)
to = vehicle.Title()
if v != nil {
lp.coordinator.Acquire(v)
to = v.Title()
}
lp.vehicle = vehicle
lp.vehicle = v
lp.vehicleMux.Unlock()
lp.log.INFO.Printf("vehicle updated: %s -> %s", from, to)
if from != to {
lp.log.INFO.Printf("vehicle updated: %s -> %s", from, to)
}
// lock api
// reset session values
lp.Lock()
// reset minSoc and targetSoc before change
lp.setMinSoc(0)
lp.setTargetSoc(100)
// reset target energy
lp.setTargetEnergy(0)
// unlock api
lp.setLimitSoc(0)
lp.setLimitEnergy(0)
lp.Unlock()
if vehicle != nil {
if v != nil {
lp.socUpdated = time.Time{}
// resolve optional config
@ -144,36 +135,43 @@ func (lp *Loadpoint) setActiveVehicle(vehicle api.Vehicle) {
if lp.Soc.Estimate == nil || *lp.Soc.Estimate {
estimate = true
}
lp.socEstimator = soc.NewEstimator(lp.log, lp.charger, vehicle, estimate)
lp.socEstimator = soc.NewEstimator(lp.log, lp.charger, v, estimate)
lp.publish(vehiclePresent, true)
lp.publish(vehicleTitle, vehicle.Title())
lp.publish(vehicleIcon, vehicle.Icon())
lp.publish(vehicleCapacity, vehicle.Capacity())
lp.restoreVehicleSettings()
lp.publish(keys.VehiclePresent, true)
lp.publish(keys.VehicleTitle, v.Title())
lp.publish(keys.VehicleName, vehicle.Settings(lp.log, v).Name())
lp.publish(keys.VehicleIcon, v.Icon())
lp.publish(keys.VehicleCapacity, v.Capacity())
if mode, ok := v.OnIdentified().GetMode(); ok {
lp.SetMode(mode)
}
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, 0.0)
lp.publish(vehicleOdometer, 0.0)
lp.publish(keys.VehicleSoc, 0)
lp.publish(keys.VehiclePresent, false)
lp.publish(keys.VehicleTitle, "")
lp.publish(keys.VehicleName, "")
lp.publish(keys.VehicleIcon, "")
lp.publish(keys.VehicleCapacity, 0.0)
lp.publish(keys.VehicleOdometer, 0.0)
}
// re-publish vehicle settings
lp.publish(phasesActive, lp.activePhases())
lp.publish(keys.PhasesActive, lp.activePhases())
lp.unpublishVehicle()
// publish effective values
lp.PublishEffectiveValues()
lp.updateSession(func(session *session.Session) {
var title string
if vehicle != nil {
title = vehicle.Title()
if v != nil {
title = v.Title()
}
lp.session.Vehicle = title
@ -202,10 +200,10 @@ func (lp *Loadpoint) wakeUpVehicle() {
func (lp *Loadpoint) unpublishVehicle() {
lp.vehicleSoc = 0
lp.publish("climaterActive", nil)
lp.publish(vehicleSoc, 0.0)
lp.publish(vehicleRange, int64(0))
lp.publish(vehicleTargetSoc, 0.0)
lp.publish(keys.VehicleClimaterActive, nil)
lp.publish(keys.VehicleSoc, 0.0)
lp.publish(keys.VehicleRange, int64(0))
lp.publish(keys.VehicleTargetSoc, 0.0)
lp.setRemainingEnergy(0)
lp.setRemainingDuration(0)
@ -227,40 +225,6 @@ func (lp *Loadpoint) publishVehicleFeature(f api.Feature) {
lp.publish("vehicleFeature"+f.String(), lp.vehicleHasFeature(f))
}
// persistVehicleSettings stores user configuration (via UI/API) for the current vehicle
func (lp *Loadpoint) persistVehicleSettings() {
idx := lp.coordinator.GetVehicleIndex(lp.GetVehicle())
if idx == -1 {
return
}
settings.SetInt(fmt.Sprintf("vehicle.%d.targetSoc", idx), int64(lp.Soc.target))
settings.SetFloat(fmt.Sprintf("vehicle.%d.targetEnergy", idx), lp.targetEnergy)
settings.SetInt(fmt.Sprintf("vehicle.%d.minSoc", idx), int64(lp.Soc.min))
settings.SetTime(fmt.Sprintf("vehicle.%d.targetTime", idx), lp.targetTime)
}
// restoreVehicleSettings restores user configuration (via UI/API) for the current vehicle
func (lp *Loadpoint) restoreVehicleSettings() {
idx := lp.coordinator.GetVehicleIndex(lp.GetVehicle())
if idx == -1 {
return
}
if v, err := settings.Int(fmt.Sprintf("vehicle.%d.targetSoc", idx)); err == nil {
lp.setTargetSoc(int(v))
}
if v, err := settings.Float(fmt.Sprintf("vehicle.%d.targetEnergy", idx)); err == nil {
lp.setTargetEnergy(v)
}
if v, err := settings.Int(fmt.Sprintf("vehicle.%d.minSoc", idx)); err == nil {
lp.setMinSoc(int(v))
}
if v, err := settings.Time(fmt.Sprintf("vehicle.%d.targetTime", idx)); err == nil {
if v.After(time.Now()) {
lp.setTargetTime(v)
}
}
}
// 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.
@ -310,7 +274,7 @@ func (lp *Loadpoint) startVehicleDetection() {
lp.vehicleDetect = lp.clock.Now()
lp.vehicleDetectTicker = lp.clock.Ticker(vehicleDetectInterval)
lp.publish(vehicleDetectionActive, true)
lp.publish(keys.VehicleDetectionActive, true)
}
// stopVehicleDetection expires the connection timer and ticker
@ -319,7 +283,7 @@ func (lp *Loadpoint) stopVehicleDetection() {
if lp.vehicleDetectTicker != nil {
lp.vehicleDetectTicker.Stop()
}
lp.publish(vehicleDetectionActive, false)
lp.publish(keys.VehicleDetectionActive, false)
}
// identifyVehicleByStatus validates if the active vehicle is still connected to the loadpoint
@ -345,7 +309,7 @@ func (lp *Loadpoint) vehicleOdometer() {
if vs, ok := lp.GetVehicle().(api.VehicleOdometer); ok {
if odo, err := vs.Odometer(); err == nil {
lp.log.DEBUG.Printf("vehicle odometer: %.0fkm", odo)
lp.publish(vehicleOdometer, odo)
lp.publish(keys.VehicleOdometer, odo)
// update session once odometer is read
lp.updateSession(func(session *session.Session) {
@ -406,7 +370,7 @@ func (lp *Loadpoint) vehicleClimateActive() bool {
lp.log.DEBUG.Println("climater active")
}
lp.publish("climaterActive", active)
lp.publish(keys.VehicleClimaterActive, active)
return active
}

View file

@ -44,7 +44,7 @@ func TestPublishSocAndRange(t *testing.T) {
MinCurrent: minA,
MaxCurrent: maxA,
phases: 1,
Mode: api.ModeNow,
mode: api.ModeNow,
}
// populate channels
@ -147,7 +147,7 @@ func TestDefaultVehicle(t *testing.T) {
ctrl := gomock.NewController(t)
mode := api.ModePV
targetsoc := 80
current := 66.6
dflt := api.NewMockVehicle(ctrl)
dflt.EXPECT().Title().Return("default").AnyTimes()
@ -155,8 +155,8 @@ func TestDefaultVehicle(t *testing.T) {
dflt.EXPECT().Capacity().AnyTimes()
dflt.EXPECT().Phases().AnyTimes()
dflt.EXPECT().OnIdentified().Return(api.ActionConfig{
Mode: &mode,
TargetSoc: &targetsoc,
Mode: mode,
MinCurrent: current,
}).AnyTimes()
vehicle := api.NewMockVehicle(ctrl)
@ -166,22 +166,10 @@ func TestDefaultVehicle(t *testing.T) {
vehicle.EXPECT().Phases().AnyTimes()
vehicle.EXPECT().OnIdentified().AnyTimes()
lp := NewLoadpoint(util.NewLogger("foo"))
lp := NewLoadpoint(util.NewLogger("foo"), nil)
lp.Mode_ = api.ModeOff // ondisconnect
lp.defaultVehicle = dflt
// ondisconnect
off := api.ModeOff
zero := 0
onDisconnect := api.ActionConfig{
Mode: &off,
MinCurrent: &lp.MinCurrent,
MaxCurrent: &lp.MaxCurrent,
Priority: &zero,
}
lp.collectDefaults()
assert.Equal(t, lp.onDisconnect, onDisconnect)
// populate channels
x, y, z := createChannels(t)
attachChannels(lp, x, y, z)
@ -196,16 +184,18 @@ func TestDefaultVehicle(t *testing.T) {
// non-default vehicle identified
lp.setActiveVehicle(vehicle)
assert.Equal(t, vehicle, lp.vehicle, "expected vehicle "+title(vehicle))
assert.Equal(t, 6.0, lp.effectiveMinCurrent(), "current")
// non-default vehicle disconnected
lp.evVehicleDisconnectHandler()
assert.Equal(t, dflt, lp.vehicle, "expected default vehicle")
assert.Equal(t, mode, lp.GetMode(), "mode")
assert.Equal(t, current, lp.effectiveMinCurrent(), "current")
// default vehicle disconnected
lp.ResetOnDisconnect = true
// default vehicle disconnected and reconnected
lp.evVehicleDisconnectHandler()
assert.Equal(t, mode, lp.GetMode(), "mode")
assert.Equal(t, lp.onDisconnect, onDisconnect, "ondisconnect must remain untouched")
assert.Equal(t, current, lp.effectiveMinCurrent(), "current")
// set non-default vehicle during disconnect - should be default on connect
lp.tasks.Clear()
@ -218,80 +208,6 @@ func TestDefaultVehicle(t *testing.T) {
assert.Nil(t, lp.vehicle, "expected no vehicle")
}
func TestApplyVehicleDefaults(t *testing.T) {
ctrl := gomock.NewController(t)
newConfig := func(mode api.ChargeMode, minCurrent, maxCurrent float64, targetSoc *int) api.ActionConfig {
return api.ActionConfig{
Mode: &mode,
MinCurrent: &minCurrent,
MaxCurrent: &maxCurrent,
TargetSoc: targetSoc,
}
}
assertConfig := func(lp *Loadpoint, conf api.ActionConfig) {
assert.Equal(t, *conf.Mode, lp.Mode)
assert.Equal(t, *conf.MinCurrent, lp.MinCurrent)
assert.Equal(t, *conf.MaxCurrent, lp.MaxCurrent)
}
// onIdentified config
targetSoc := 99
oi := newConfig(api.ModePV, 7, 15, &targetSoc)
// onDefault config
od := newConfig(api.ModeOff, 6, 16, nil)
vehicle := api.NewMockVehicle(ctrl)
vehicle.EXPECT().Title().Return("it's me").AnyTimes()
vehicle.EXPECT().Icon().Return("").AnyTimes()
vehicle.EXPECT().Capacity().AnyTimes()
vehicle.EXPECT().Phases().AnyTimes()
vehicle.EXPECT().OnIdentified().Return(oi).AnyTimes()
lp := NewLoadpoint(util.NewLogger("foo"))
// populate channels
x, y, z := createChannels(t)
attachChannels(lp, x, y, z)
lp.onDisconnect = od
lp.ResetOnDisconnect = true
// check loadpoint default currents can't be violated
lp.applyAction(newConfig(*od.Mode, 5, 17, od.TargetSoc))
assertConfig(lp, od)
// vehicle identified
lp.setActiveVehicle(vehicle)
assertConfig(lp, oi)
// vehicle disconnected
vehicle.EXPECT().Phases().AnyTimes()
lp.evVehicleDisconnectHandler()
assertConfig(lp, od)
// identify vehicle by id
charger := struct {
*api.MockCharger
*api.MockIdentifier
}{
MockCharger: api.NewMockCharger(ctrl),
MockIdentifier: api.NewMockIdentifier(ctrl),
}
lp.charger = charger
lp.coordinator = coordinator.NewAdapter(lp, coordinator.New(util.NewLogger("foo"), []api.Vehicle{vehicle}))
const id = "don't call me stacey"
charger.MockIdentifier.EXPECT().Identify().Return(id, nil)
vehicle.EXPECT().Identifiers().Return([]string{id})
lp.identifyVehicle()
assertConfig(lp, oi)
}
func TestReconnectVehicle(t *testing.T) {
tc := []struct {
name string
@ -336,7 +252,7 @@ func TestReconnectVehicle(t *testing.T) {
MinCurrent: minA,
MaxCurrent: maxA,
phases: 1,
Mode: api.ModeNow,
mode: api.ModeNow,
}
lp.coordinator = coordinator.NewAdapter(lp, coordinator.New(util.NewLogger("foo"), []api.Vehicle{vehicle}))

View file

@ -26,7 +26,7 @@ func (p *Prioritizer) UpdateChargePowerFlexibility(lp loadpoint.API) {
}
func (p *Prioritizer) GetChargePowerFlexibility(lp loadpoint.API) float64 {
prio := lp.GetPriority()
prio := lp.EffectivePriority()
var (
reduceBy float64
@ -36,12 +36,12 @@ func (p *Prioritizer) GetChargePowerFlexibility(lp loadpoint.API) float64 {
for lp, power := range p.demand {
if lp.GetPriority() < prio {
reduceBy += power
msg += fmt.Sprintf("%.0fW from %s at prio %d, ", power, lp.Title(), lp.GetPriority())
msg += fmt.Sprintf("%.0fW from %s at prio %d, ", power, lp.Title(), lp.EffectivePriority())
}
}
if p.log != nil && reduceBy > 0 {
p.log.DEBUG.Printf("lp %s at prio %d gets additional %stotal %.0fW\n", lp.Title(), lp.GetPriority(), msg, reduceBy)
p.log.DEBUG.Printf("lp %s at prio %d gets additional %stotal %.0fW\n", lp.Title(), lp.EffectivePriority(), msg, reduceBy)
}
return reduceBy

View file

@ -15,11 +15,13 @@ func TestPrioritzer(t *testing.T) {
lo := loadpoint.NewMockAPI(ctrl)
lo.EXPECT().Title().AnyTimes()
lo.EXPECT().GetPriority().Return(0).AnyTimes()
lo.EXPECT().GetPriority().Return(0).AnyTimes() // prio 0
lo.EXPECT().EffectivePriority().Return(0).AnyTimes() // prio 0
hi := loadpoint.NewMockAPI(ctrl)
hi.EXPECT().Title().AnyTimes()
hi.EXPECT().GetPriority().Return(1).AnyTimes()
hi.EXPECT().GetPriority().Return(1).AnyTimes() // prio 1
hi.EXPECT().EffectivePriority().Return(1).AnyTimes() // prio 1
// no additional power available
lo.EXPECT().GetChargePowerFlexibility().Return(300.0)

107
core/settings.go Normal file
View file

@ -0,0 +1,107 @@
package core
import (
"time"
"github.com/evcc-io/evcc/server/db/settings"
)
// type Settings interface {
// SetString(val string)
// SetInt(val int64)
// SetFloat(val float64)
// SetTime(val time.Time)
// SetJson(val any) error
// SetBool(val bool)
// String(string, error)
// Int(int64, error)
// Float(float64, error)
// Time(time.Time, error)
// Bool(bool, error)
// Json(res any) error
// }
type Settings struct {
Key string
}
func (s *Settings) SetString(key string, val string) {
if s == nil {
return
}
settings.SetString(s.Key+key, val)
}
func (s *Settings) SetInt(key string, val int64) {
if s == nil {
return
}
settings.SetInt(s.Key+key, val)
}
func (s *Settings) SetFloat(key string, val float64) {
if s == nil {
return
}
settings.SetFloat(s.Key+key, val)
}
func (s *Settings) SetTime(key string, val time.Time) {
if s == nil {
return
}
settings.SetTime(s.Key+key, val)
}
func (s *Settings) SetBool(key string, val bool) {
if s == nil {
return
}
settings.SetBool(s.Key+key, val)
}
// func (s *Settings) SetJson(key string, val any) error {
// if s == nil {
// return nil
// }
// return settings.SetJson(s.Key+key, val)
// }
func (s *Settings) String(key string) (string, error) {
if s == nil {
return "", nil
}
return settings.String(s.Key + key)
}
func (s *Settings) Int(key string) (int64, error) {
if s == nil {
return 0, nil
}
return settings.Int(s.Key + key)
}
func (s *Settings) Float(key string) (float64, error) {
if s == nil {
return 0, nil
}
return settings.Float(s.Key + key)
}
func (s *Settings) Time(key string) (time.Time, error) {
if s == nil {
return time.Time{}, nil
}
return settings.Time(s.Key + key)
}
func (s *Settings) Bool(key string) (bool, error) {
if s == nil {
return false, nil
}
return settings.Bool(s.Key + key)
}
// func (s *Settings) Json(key string, res any) error {
// return settings.Json(s.Key+key, &res)
// }

View file

@ -11,11 +11,13 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/cmd/shutdown"
"github.com/evcc-io/evcc/core/coordinator"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/planner"
"github.com/evcc-io/evcc/core/prioritizer"
"github.com/evcc-io/evcc/core/session"
"github.com/evcc-io/evcc/core/soc"
"github.com/evcc-io/evcc/core/vehicle"
"github.com/evcc-io/evcc/push"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/server/db/settings"
@ -55,7 +57,7 @@ type Site struct {
*Health
sync.Mutex
sync.RWMutex
log *util.Logger
// configuration
@ -63,9 +65,6 @@ type Site struct {
Voltage float64 `mapstructure:"voltage"` // Operating voltage. 230V for Germany.
ResidualPower float64 `mapstructure:"residualPower"` // PV meter only: household usage. Grid meter: household safety margin
Meters MetersConfig // Meter references
PrioritySoc float64 `mapstructure:"prioritySoc"` // prefer battery up to this Soc
BufferSoc float64 `mapstructure:"bufferSoc"` // continue charging on battery above this Soc
BufferStartSoc float64 `mapstructure:"bufferStartSoc"` // start charging on battery above this Soc
MaxGridSupplyWhileBatteryCharging float64 `mapstructure:"maxGridSupplyWhileBatteryCharging"` // ignore battery charging if AC consumption is above this value
SmartCostLimit float64 `mapstructure:"smartCostLimit"` // always charge if cost is below this value
BatteryDischargeControl bool `mapstructure:"batteryDischargeControl"` // prevent battery discharge for fast and planned charging
@ -76,6 +75,11 @@ type Site struct {
batteryMeters []api.Meter // Battery charging meters
auxMeters []api.Meter // Auxiliary meters
// battery settings
prioritySoc float64 // prefer battery up to this Soc
bufferSoc float64 // continue charging on battery above this Soc
bufferStartSoc float64 // start charging on battery above this Soc
tariffs tariff.Tariffs // Tariff
loadpoints []*Loadpoint // Loadpoints
coordinator *coordinator.Coordinator // Vehicles
@ -94,12 +98,10 @@ type Site struct {
// MetersConfig contains the loadpoint's meter configuration
type MetersConfig struct {
GridMeterRef string `mapstructure:"grid"` // Grid usage meter
PVMetersRef []string `mapstructure:"pv"` // PV meter
PVMetersRef_ []string `mapstructure:"pvs"` // TODO deprecated
BatteryMetersRef []string `mapstructure:"battery"` // Battery charging meter
BatteryMetersRef_ []string `mapstructure:"batteries"` // TODO deprecated
AuxMetersRef []string `mapstructure:"aux"` // Auxiliary meters
GridMeterRef string `mapstructure:"grid"` // Grid usage meter
PVMetersRef []string `mapstructure:"pv"` // PV meter
BatteryMetersRef []string `mapstructure:"battery"` // Battery charging meter
AuxMetersRef []string `mapstructure:"aux"` // Auxiliary meters
}
// NewSiteFromConfig creates a new site
@ -118,14 +120,13 @@ func NewSiteFromConfig(
site.loadpoints = loadpoints
site.tariffs = tariffs
site.coordinator = coordinator.New(log, config.Instances(config.Vehicles().Devices()))
config.Vehicles().Subscribe(site.updateVehicles)
handler := config.Vehicles()
site.coordinator = coordinator.New(log, config.Instances(handler.Devices()))
handler.Subscribe(site.updateVehicles)
site.prioritizer = prioritizer.New(log)
site.stats = NewStats()
site.restoreSettings()
// upload telemetry on shutdown
if telemetry.Enabled() {
shutdown.Register(func() {
@ -165,7 +166,7 @@ func NewSiteFromConfig(
}
// multiple pv
for _, ref := range append(site.Meters.PVMetersRef, site.Meters.PVMetersRef_...) {
for _, ref := range site.Meters.PVMetersRef {
dev, err := config.Meters().ByName(ref)
if err != nil {
return nil, err
@ -173,13 +174,8 @@ func NewSiteFromConfig(
site.pvMeters = append(site.pvMeters, dev.Instance())
}
// TODO deprecated
if len(site.Meters.PVMetersRef_) > 0 {
site.log.WARN.Println("deprecated: use 'pv' instead of 'pvs'")
}
// multiple batteries
for _, ref := range append(site.Meters.BatteryMetersRef, site.Meters.BatteryMetersRef_...) {
for _, ref := range site.Meters.BatteryMetersRef {
dev, err := config.Meters().ByName(ref)
if err != nil {
return nil, err
@ -187,11 +183,6 @@ func NewSiteFromConfig(
site.batteryMeters = append(site.batteryMeters, dev.Instance())
}
// TODO deprecated
if len(site.Meters.BatteryMetersRef_) > 0 {
site.log.WARN.Println("deprecated: use 'battery' instead of 'batteries'")
}
if len(site.batteryMeters) > 0 && site.ResidualPower <= 0 {
site.log.WARN.Println("battery configured but residualPower is missing or <= 0 (add residualPower: 100 to site), see https://docs.evcc.io/en/docs/reference/configuration/site#residualpower")
}
@ -210,14 +201,6 @@ func NewSiteFromConfig(
return nil, errors.New("missing either grid or pv meter")
}
if site.BufferStartSoc != 0 && site.BufferStartSoc <= site.BufferSoc {
site.log.WARN.Println("bufferStartSoc must be larger than bufferSoc")
}
if site.BufferSoc != 0 && site.BufferSoc <= site.PrioritySoc {
site.log.WARN.Println("bufferSoc must be larger than prioritySoc")
}
// revert battery mode on shutdown
shutdown.Register(func() {
if mode := site.GetBatteryMode(); mode != api.BatteryUnknown && mode != api.BatteryNormal {
@ -241,31 +224,32 @@ func NewSite() *Site {
return lp
}
// Loadpoints returns the array of associated loadpoints
func (site *Site) Loadpoints() []loadpoint.API {
res := make([]loadpoint.API, len(site.loadpoints))
for id, lp := range site.loadpoints {
res[id] = lp
// restoreSettings restores site settings
func (site *Site) restoreSettings() error {
if v, err := settings.Float(keys.BufferSoc); err == nil {
if err := site.SetBufferSoc(v); err != nil {
return err
}
}
return res
}
func (site *Site) restoreSettings() {
if v, err := settings.Float("site.bufferSoc"); err == nil {
site.BufferSoc = v
if v, err := settings.Float(keys.BufferStartSoc); err == nil {
if err := site.SetBufferStartSoc(v); err != nil {
return err
}
}
if v, err := settings.Float("site.bufferStartSoc"); err == nil {
site.BufferStartSoc = v
if v, err := settings.Float(keys.SmartCostLimit); err == nil {
if err := site.SetSmartCostLimit(v); err != nil {
return err
}
}
if v, err := settings.Float("site.prioritySoc"); err == nil {
site.PrioritySoc = v
if v, err := settings.Float(keys.PrioritySoc); err == nil {
if err := site.SetPrioritySoc(v); err != nil {
return err
}
}
if v, err := settings.Float("site.smartCostLimit"); err == nil {
site.SmartCostLimit = v
}
if v, err := settings.Bool("site.batteryDischargeControl"); err == nil {
if v, err := settings.Bool(keys.BatteryDischargeControl); err == nil {
site.BatteryDischargeControl = v
}
return nil
}
func meterCapabilities(name string, meter interface{}) string {
@ -285,10 +269,10 @@ func meterCapabilities(name string, meter interface{}) string {
// DumpConfig site configuration
func (site *Site) DumpConfig() {
// verify vehicle detection
if vehicles := site.GetVehicles(); len(vehicles) > 1 {
if vehicles := site.Vehicles().All(); len(vehicles) > 1 {
for _, v := range vehicles {
if _, ok := v.(api.ChargeState); !ok {
site.log.WARN.Printf("vehicle '%s' does not support automatic detection", v.Title())
site.log.WARN.Printf("vehicle '%s' does not support automatic detection", v.Instance().Title())
}
}
}
@ -322,7 +306,7 @@ func (site *Site) DumpConfig() {
}
}
if vehicles := site.GetVehicles(); len(vehicles) > 0 {
if vehicles := site.Vehicles().All(); len(vehicles) > 0 {
site.log.INFO.Println(" vehicles:")
for i, v := range vehicles {
@ -464,11 +448,11 @@ func (site *Site) updateMeters() error {
}
site.log.DEBUG.Printf("pv power: %.0fW", site.pvPower)
site.publish("pvPower", site.pvPower)
site.publish(keys.PvPower, site.pvPower)
site.publish("pvEnergy", totalEnergy)
site.publish(keys.PvEnergy, totalEnergy)
site.publish("pv", mm)
site.publish(keys.Pv, mm)
}
if len(site.batteryMeters) > 0 {
@ -541,7 +525,7 @@ func (site *Site) updateMeters() error {
}
}
site.publish("batteryCapacity", totalCapacity)
site.publish(keys.BatteryCapacity, totalCapacity)
// convert weighed socs to total soc
if totalCapacity == 0 {
@ -550,14 +534,14 @@ func (site *Site) updateMeters() error {
site.batterySoc /= totalCapacity
site.log.DEBUG.Printf("battery soc: %.0f%%", math.Round(site.batterySoc))
site.publish("batterySoc", site.batterySoc)
site.publish(keys.BatterySoc, site.batterySoc)
site.log.DEBUG.Printf("battery power: %.0fW", site.batteryPower)
site.publish("batteryPower", site.batteryPower)
site.publish(keys.BatteryPower, site.batteryPower)
site.publish("batteryEnergy", totalEnergy)
site.publish(keys.BatteryEnergy, totalEnergy)
site.publish("battery", mm)
site.publish(keys.Battery, mm)
}
// grid power
@ -570,7 +554,7 @@ func (site *Site) updateMeters() error {
if err == nil {
phases := []float64{p1, p2, p3}
site.log.DEBUG.Printf("grid powers: %.0fW", phases)
site.publish("gridPowers", phases)
site.publish(keys.GridPowers, phases)
} else {
err = fmt.Errorf("grid powers: %w", err)
}
@ -583,7 +567,7 @@ func (site *Site) updateMeters() error {
if err == nil {
phases := []float64{util.SignFromPower(i1, p1), util.SignFromPower(i2, p2), util.SignFromPower(i3, p3)}
site.log.DEBUG.Printf("grid currents: %.3gA", phases)
site.publish("gridCurrents", phases)
site.publish(keys.GridCurrents, phases)
} else {
err = fmt.Errorf("grid currents: %w", err)
}
@ -594,7 +578,7 @@ func (site *Site) updateMeters() error {
var f float64
f, err = energyMeter.TotalEnergy()
if err == nil {
site.publish("gridEnergy", f)
site.publish(keys.GridEnergy, f)
} else {
site.log.ERROR.Printf("grid energy: %v", err)
}
@ -624,7 +608,7 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b
site.pvPower = 0
}
site.log.DEBUG.Printf("pv power: %.0fW", site.pvPower)
site.publish("pvPower", site.pvPower)
site.publish(keys.PvPower, site.pvPower)
}
// honour battery priority
@ -634,17 +618,17 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b
var batteryBuffered, batteryStart bool
if len(site.batteryMeters) > 0 {
site.Lock()
defer site.Unlock()
site.RLock()
defer site.RUnlock()
// if battery is charging below prioritySoc give it priority
if site.batterySoc < site.PrioritySoc && batteryPower < 0 {
site.log.DEBUG.Printf("battery has priority at soc %.0f%% (< %.0f%%)", site.batterySoc, site.PrioritySoc)
if site.batterySoc < site.prioritySoc && batteryPower < 0 {
site.log.DEBUG.Printf("battery has priority at soc %.0f%% (< %.0f%%)", site.batterySoc, site.prioritySoc)
batteryPower = 0
} else {
// if battery is above bufferSoc allow using it for charging
batteryBuffered = site.BufferSoc > 0 && site.batterySoc > site.BufferSoc
batteryStart = site.BufferStartSoc > 0 && site.batterySoc > site.BufferStartSoc
batteryBuffered = site.bufferSoc > 0 && site.batterySoc > site.bufferSoc
batteryStart = site.bufferStartSoc > 0 && site.batterySoc > site.bufferStartSoc
}
}
@ -668,9 +652,9 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b
sitePower -= auxPower
site.log.DEBUG.Printf("aux power: %.0fW", auxPower)
site.publish("auxPower", auxPower)
site.publish(keys.AuxPower, auxPower)
site.publish("aux", mm)
site.publish(keys.Aux, mm)
}
// handle priority
@ -728,29 +712,29 @@ func (site *Site) effectiveCo2(greenShare float64) *float64 {
}
func (site *Site) publishTariffs(greenShareHome float64, greenShareLoadpoints float64) {
site.publish("greenShareHome", greenShareHome)
site.publish("greenShareLoadpoints", greenShareLoadpoints)
site.publish(keys.GreenShareHome, greenShareHome)
site.publish(keys.GreenShareLoadpoints, greenShareLoadpoints)
if gridPrice, err := site.tariffs.CurrentGridPrice(); err == nil {
site.publishDelta("tariffGrid", gridPrice)
site.publishDelta(keys.TariffGrid, gridPrice)
}
if feedInPrice, err := site.tariffs.CurrentFeedInPrice(); err == nil {
site.publishDelta("tariffFeedIn", feedInPrice)
site.publishDelta(keys.TariffFeedIn, feedInPrice)
}
if co2, err := site.tariffs.CurrentCo2(); err == nil {
site.publishDelta("tariffCo2", co2)
site.publishDelta(keys.TariffCo2, co2)
}
if price := site.effectivePrice(greenShareHome); price != nil {
site.publish("tariffPriceHome", price)
site.publish(keys.TariffPriceHome, price)
}
if co2 := site.effectiveCo2(greenShareHome); co2 != nil {
site.publish("tariffCo2Home", co2)
site.publish(keys.TariffCo2Home, co2)
}
if price := site.effectivePrice(greenShareLoadpoints); price != nil {
site.publish("tariffPriceLoadpoints", price)
site.publish(keys.TariffPriceLoadpoints, price)
}
if co2 := site.effectiveCo2(greenShareLoadpoints); co2 != nil {
site.publish("tariffCo2Loadpoints", co2)
site.publish(keys.TariffCo2Loadpoints, co2)
}
}
@ -772,7 +756,7 @@ func (site *Site) update(lp Updater) {
flexiblePower = site.prioritizer.GetChargePowerFlexibility(lp)
}
var autoCharge bool
var smartCostActive bool
if tariff := site.GetTariff(PlannerTariff); tariff != nil {
rates, err := tariff.Rates()
@ -783,10 +767,10 @@ func (site *Site) update(lp Updater) {
if err == nil {
limit := site.GetSmartCostLimit()
autoCharge = limit != 0 && rate.Price <= limit
site.publish("smartCostActive", autoCharge)
smartCostActive = limit != 0 && rate.Price <= limit
site.publish(keys.SmartCostActive, smartCostActive)
} else {
site.log.ERROR.Println("tariff:", err)
site.log.ERROR.Println("smartCost:", err)
}
}
@ -794,12 +778,12 @@ func (site *Site) update(lp Updater) {
// ignore negative pvPower values as that means it is not an energy source but consumption
homePower := site.gridPower + max(0, site.pvPower) + site.batteryPower - totalChargePower
homePower = max(homePower, 0)
site.publish("homePower", homePower)
site.publish(keys.HomePower, homePower)
greenShareHome := site.greenShare(0, homePower)
greenShareLoadpoints := site.greenShare(homePower, homePower+totalChargePower)
lp.Update(sitePower, autoCharge, batteryBuffered, batteryStart, greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints))
lp.Update(sitePower, smartCostActive, batteryBuffered, batteryStart, greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints))
site.Health.Update()
@ -825,26 +809,32 @@ func (site *Site) update(lp Updater) {
// prepare publishes initial values
func (site *Site) prepare() {
site.publish("siteTitle", site.Title)
site.publish(keys.SiteTitle, site.Title)
site.publish("gridConfigured", site.gridMeter != nil)
site.publish("pvConfigured", len(site.pvMeters) > 0)
site.publish("batteryConfigured", len(site.batteryMeters) > 0)
site.publish("bufferSoc", site.BufferSoc)
site.publish("bufferStartSoc", site.BufferStartSoc)
site.publish("prioritySoc", site.PrioritySoc)
site.publish("residualPower", site.ResidualPower)
site.publish("smartCostLimit", site.SmartCostLimit)
site.publish("smartCostType", nil)
site.publish("smartCostActive", false)
site.publish(keys.GridConfigured, site.gridMeter != nil)
site.publish(keys.PvConfigured, len(site.pvMeters) > 0)
site.publish(keys.BatteryConfigured, len(site.batteryMeters) > 0)
site.publish(keys.BufferSoc, site.bufferSoc)
site.publish(keys.BufferStartSoc, site.bufferStartSoc)
site.publish(keys.PrioritySoc, site.prioritySoc)
site.publish(keys.ResidualPower, site.ResidualPower)
site.publish(keys.SmartCostLimit, site.SmartCostLimit)
site.publish(keys.SmartCostType, nil)
site.publish(keys.SmartCostActive, false)
if tariff := site.GetTariff(PlannerTariff); tariff != nil {
site.publish("smartCostType", tariff.Type())
site.publish(keys.SmartCostType, tariff.Type().String())
}
site.publish("currency", site.tariffs.Currency)
site.publish(keys.Currency, site.tariffs.Currency.String())
site.publish("vehicles", vehicleTitles(site.GetVehicles()))
site.publish("batteryDischargeControl", site.BatteryDischargeControl)
site.publish("batteryMode", site.batteryMode)
site.publish(keys.BatteryDischargeControl, site.BatteryDischargeControl)
site.publish(keys.BatteryMode, site.batteryMode.String())
if err := site.restoreSettings(); err != nil {
site.log.ERROR.Println(err)
}
site.publishVehicles()
vehicle.Publish = site.publishVehicles
}
// Prepare attaches communication channels to site and loadpoints

View file

@ -9,6 +9,7 @@ import (
type API interface {
Healthy() bool
Loadpoints() []loadpoint.API
Vehicles() Vehicles
//
// battery
@ -28,13 +29,6 @@ type API interface {
GetResidualPower() float64
SetResidualPower(float64) error
//
// vehicles
//
// GetVehicles is the list of vehicles
GetVehicles() []api.Vehicle
//
// tariffs and costs
//

13
core/site/vehicles.go Normal file
View file

@ -0,0 +1,13 @@
package site
import (
"github.com/evcc-io/evcc/core/vehicle"
)
type Vehicles interface {
// All returns the list of vehicle adapters
All() []vehicle.API
// ByName returns a single vehicle adapter by name
ByName(string) (vehicle.API, error)
}

View file

@ -4,6 +4,8 @@ import (
"errors"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/server/db/settings"
)
@ -18,11 +20,25 @@ const (
PlannerTariff = "planner"
)
// Loadpoints returns the list loadpoints
func (site *Site) Loadpoints() []loadpoint.API {
res := make([]loadpoint.API, len(site.loadpoints))
for id, lp := range site.loadpoints {
res[id] = lp
}
return res
}
// Vehicles returns the site vehicles
func (site *Site) Vehicles() site.Vehicles {
return &vehicles{log: site.log}
}
// GetPrioritySoc returns the PrioritySoc
func (site *Site) GetPrioritySoc() float64 {
site.Lock()
defer site.Unlock()
return site.PrioritySoc
site.RLock()
defer site.RUnlock()
return site.prioritySoc
}
// SetPrioritySoc sets the PrioritySoc
@ -34,22 +50,26 @@ func (site *Site) SetPrioritySoc(soc float64) error {
return ErrBatteryNotConfigured
}
if site.bufferSoc != 0 && soc > site.bufferSoc {
return errors.New("priority soc must be smaller or equal than buffer soc")
}
site.log.DEBUG.Println("set priority soc:", soc)
if site.PrioritySoc != soc {
site.PrioritySoc = soc
settings.SetFloat("site.prioritySoc", site.PrioritySoc)
site.publish("prioritySoc", site.PrioritySoc)
if site.prioritySoc != soc {
site.prioritySoc = soc
settings.SetFloat(keys.PrioritySoc, site.prioritySoc)
site.publish(keys.PrioritySoc, site.prioritySoc)
}
return nil
}
// GetBufferSoc returns the BufferSoc
func (site *Site) GetBufferSoc() float64 {
site.Lock()
defer site.Unlock()
return site.BufferSoc
site.RLock()
defer site.RUnlock()
return site.bufferSoc
}
// SetBufferSoc sets the BufferSoc
@ -61,12 +81,20 @@ func (site *Site) SetBufferSoc(soc float64) error {
return ErrBatteryNotConfigured
}
if soc != 0 && soc <= site.prioritySoc {
return errors.New("buffer soc must be larger than priority soc")
}
if site.bufferStartSoc != 0 && soc > site.bufferStartSoc {
return errors.New("buffer soc must be smaller or equal than buffer start soc")
}
site.log.DEBUG.Println("set buffer soc:", soc)
if site.BufferSoc != soc {
site.BufferSoc = soc
settings.SetFloat("site.bufferSoc", site.BufferSoc)
site.publish("bufferSoc", site.BufferSoc)
if site.bufferSoc != soc {
site.bufferSoc = soc
settings.SetFloat(keys.BufferSoc, site.bufferSoc)
site.publish(keys.BufferSoc, site.bufferSoc)
}
return nil
@ -74,9 +102,9 @@ func (site *Site) SetBufferSoc(soc float64) error {
// GetBufferStartSoc returns the BufferStartSoc
func (site *Site) GetBufferStartSoc() float64 {
site.Lock()
defer site.Unlock()
return site.BufferStartSoc
site.RLock()
defer site.RUnlock()
return site.bufferStartSoc
}
// SetBufferStartSoc sets the BufferStartSoc
@ -88,12 +116,16 @@ func (site *Site) SetBufferStartSoc(soc float64) error {
return ErrBatteryNotConfigured
}
if soc != 0 && soc <= site.bufferSoc {
return errors.New("buffer start soc must be larger than buffer soc")
}
site.log.DEBUG.Println("set buffer start soc:", soc)
if site.BufferStartSoc != soc {
site.BufferStartSoc = soc
settings.SetFloat("site.bufferStartSoc", site.BufferStartSoc)
site.publish("bufferStartSoc", site.BufferStartSoc)
if site.bufferStartSoc != soc {
site.bufferStartSoc = soc
settings.SetFloat(keys.BufferStartSoc, site.bufferStartSoc)
site.publish(keys.BufferStartSoc, site.bufferStartSoc)
}
return nil
@ -101,8 +133,8 @@ func (site *Site) SetBufferStartSoc(soc float64) error {
// GetResidualPower returns the ResidualPower
func (site *Site) GetResidualPower() float64 {
site.Lock()
defer site.Unlock()
site.RLock()
defer site.RUnlock()
return site.ResidualPower
}
@ -115,7 +147,7 @@ func (site *Site) SetResidualPower(power float64) error {
if site.ResidualPower != power {
site.ResidualPower = power
site.publish("residualPower", site.ResidualPower)
site.publish(keys.ResidualPower, site.ResidualPower)
}
return nil
@ -123,8 +155,8 @@ func (site *Site) SetResidualPower(power float64) error {
// GetSmartCostLimit returns the SmartCostLimit
func (site *Site) GetSmartCostLimit() float64 {
site.Lock()
defer site.Unlock()
site.RLock()
defer site.RUnlock()
return site.SmartCostLimit
}
@ -137,24 +169,17 @@ func (site *Site) SetSmartCostLimit(val float64) error {
if site.SmartCostLimit != val {
site.SmartCostLimit = val
settings.SetFloat("site.smartCostLimit", site.SmartCostLimit)
site.publish("smartCostLimit", site.SmartCostLimit)
settings.SetFloat(keys.SmartCostLimit, site.SmartCostLimit)
site.publish(keys.SmartCostLimit, site.SmartCostLimit)
}
return nil
}
// GetVehicles is the list of vehicles
func (site *Site) GetVehicles() []api.Vehicle {
site.Lock()
defer site.Unlock()
return site.coordinator.GetVehicles()
}
// GetTariff returns the respective tariff if configured or nil
func (site *Site) GetTariff(tariff string) api.Tariff {
site.Lock()
defer site.Unlock()
site.RLock()
defer site.RUnlock()
switch tariff {
case GridTariff:
@ -210,8 +235,8 @@ func (site *Site) SetBatteryDischargeControl(val bool) error {
defer site.Unlock()
site.BatteryDischargeControl = val
settings.SetBool("site.batteryDischargeControl", val)
site.publish("batteryDischargeControl", val)
settings.SetBool(keys.BatteryDischargeControl, val)
site.publish(keys.BatteryDischargeControl, val)
}
return nil

View file

@ -2,6 +2,7 @@ package core
import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/loadpoint"
)
@ -17,7 +18,7 @@ func (site *Site) SetBatteryMode(batMode api.BatteryMode) {
site.Lock()
defer site.Unlock()
site.batteryMode = batMode
site.publish("batteryMode", batMode)
site.publish(keys.BatteryMode, batMode)
}
func (site *Site) determineBatteryMode(loadpoints []loadpoint.API) api.BatteryMode {

View file

@ -1,16 +1,54 @@
package core
import (
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/core/vehicle"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/samber/lo"
)
// vehicleTitles returns a list of vehicle titles
func vehicleTitles(vehicles []api.Vehicle) []string {
return lo.Map(vehicles, func(v api.Vehicle, _ int) string {
return v.Title()
})
type planStruct struct {
Soc int `json:"soc"`
Time time.Time `json:"time"`
}
type vehicleStruct struct {
Title string `json:"title"`
MinSoc int `json:"minSoc,omitempty"`
LimitSoc int `json:"limitSoc,omitempty"`
Plans []planStruct `json:"plans,omitempty"`
}
// publishVehicles returns a list of vehicle titles
func (site *Site) publishVehicles() {
vv := site.Vehicles().All()
res := make(map[string]vehicleStruct, len(vv))
for _, v := range vv {
var plans []planStruct
// TODO: add support for multiple plans
if time, soc := v.GetPlanSoc(); !time.IsZero() {
plans = append(plans, planStruct{Soc: soc, Time: time})
}
res[v.Name()] = vehicleStruct{
Title: v.Instance().Title(),
MinSoc: v.GetMinSoc(),
LimitSoc: v.GetLimitSoc(),
Plans: plans,
}
if lp := site.coordinator.Owner(v.Instance()); lp != nil {
lp.PublishEffectiveValues()
}
}
site.publish(keys.Vehicles, res)
}
// updateVehicles adds or removes a vehicle asynchronously
@ -25,5 +63,42 @@ func (site *Site) updateVehicles(op config.Operation, dev config.Device[api.Vehi
site.coordinator.Delete(vehicle)
}
site.publish("vehicles", vehicleTitles(site.GetVehicles()))
site.publishVehicles()
}
var _ site.Vehicles = (*vehicles)(nil)
type vehicles struct {
log *util.Logger
}
func (vv *vehicles) Instances() []api.Vehicle {
devs := config.Vehicles().Devices()
res := make([]api.Vehicle, 0, len(devs))
for _, dev := range devs {
res = append(res, dev.Instance())
}
return res
}
func (vv *vehicles) All() []vehicle.API {
devs := config.Vehicles().Devices()
res := make([]vehicle.API, 0, len(devs))
for _, dev := range devs {
res = append(res, vehicle.Adapter(vv.log, dev))
}
return res
}
func (vv *vehicles) ByName(name string) (vehicle.API, error) {
dev, err := config.Vehicles().ByName(name)
if err != nil {
return nil, err
}
return vehicle.Adapter(vv.log, dev), nil
}

View file

@ -3,6 +3,7 @@ package core
import (
"time"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/util"
)
@ -35,7 +36,7 @@ func (s *Stats) Update(p publisher) {
"365d": s.calculate(365),
"total": s.calculate(365 * 100), // 100 years
}
p.publish("statistics", stats)
p.publish(keys.Statistics, stats)
s.updated = time.Now()
}

100
core/vehicle/adapter.go Normal file
View file

@ -0,0 +1,100 @@
package vehicle
import (
"errors"
"fmt"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util"
)
var _ API = (*adapter)(nil)
// Publish publishes vehicle updates at site level
var Publish func()
type adapter struct {
log *util.Logger
name string
api.Vehicle // TODO handle instance updates
}
func (v *adapter) key() string {
return fmt.Sprintf("vehicle.%s.", v.name)
}
func (v *adapter) publish() {
if Publish != nil {
Publish()
}
}
func (v *adapter) Instance() api.Vehicle {
return v.Vehicle
}
func (v *adapter) Name() string {
return v.name
}
// GetMinSoc returns the min soc
func (v *adapter) GetMinSoc() int {
if v, err := settings.Int(v.key() + keys.MinSoc); err == nil {
return int(v)
}
return 0
}
// SetMinSoc sets the min soc
func (v *adapter) SetMinSoc(soc int) {
v.log.DEBUG.Printf("set %s min soc: %d", v.name, soc)
settings.SetInt(v.key()+keys.MinSoc, int64(soc))
v.publish()
}
// GetLimitSoc returns the limit soc
func (v *adapter) GetLimitSoc() int {
if v, err := settings.Int(v.key() + keys.LimitSoc); err == nil {
return int(v)
}
return 0
}
// SetLimitSoc sets the limit soc
func (v *adapter) SetLimitSoc(soc int) {
v.log.DEBUG.Printf("set %s limit soc: %d", v.name, soc)
settings.SetInt(v.key()+keys.LimitSoc, int64(soc))
v.publish()
}
// GetPlanSoc returns the charge plan soc
func (v *adapter) GetPlanSoc() (time.Time, int) {
var ts time.Time
if v, err := settings.Time(v.key() + keys.PlanTime); err == nil {
ts = v
}
var soc int
if v, err := settings.Int(v.key() + keys.PlanSoc); err == nil {
soc = int(v)
}
return ts, soc
}
// SetPlanSoc sets the charge plan soc
func (v *adapter) SetPlanSoc(ts time.Time, soc int) error {
if !ts.IsZero() && ts.Before(time.Now()) {
return errors.New("timestamp is in the past")
}
v.log.DEBUG.Printf("set %s plan soc: %d @ %v", v.name, soc, ts.Round(time.Second).Local())
settings.SetTime(v.key()+keys.PlanTime, ts)
settings.SetInt(v.key()+keys.PlanSoc, int64(soc))
v.publish()
return nil
}

54
core/vehicle/api.go Normal file
View file

@ -0,0 +1,54 @@
package vehicle
import (
"time"
"github.com/evcc-io/evcc/api"
)
//go:generate mockgen -package vehicle -destination mock.go -mock_names API=MockAPI github.com/evcc-io/evcc/core/vehicle API
type API interface {
// Instance returns the vehicle instance
Instance() api.Vehicle
// Name returns the vehicle name
Name() string
// // GetMode returns the charge mode
// GetMode() api.ChargeMode
// // SetMode sets the charge mode
// SetMode(api.ChargeMode)
// // GetPhases returns the phases
// GetPhases() int
// // SetPhases sets the phases
// SetPhases(phases int) error
// // GetPriority returns the priority
// GetPriority() int
// // SetPriority sets the priority
// SetPriority(priority int)
// GetMinSoc returns the min soc
GetMinSoc() int
// SetMinSoc sets the min soc
SetMinSoc(soc int)
// GetLimitSoc returns the limit soc
GetLimitSoc() int
// SetLimitSoc sets the limit soc
SetLimitSoc(soc int)
// GetPlanSoc returns the charge plan soc
GetPlanSoc() (time.Time, int)
// SetPlanSoc sets the charge plan time and soc
SetPlanSoc(time.Time, int) error
// // GetMinCurrent returns the min charging current
// GetMinCurrent() float64
// // SetMinCurrent sets the min charging current
// SetMinCurrent(float64)
// // GetMaxCurrent returns the max charging current
// GetMaxCurrent() float64
// // SetMaxCurrent sets the max charging current
// SetMaxCurrent(float64)
}

49
core/vehicle/dummy.go Normal file
View file

@ -0,0 +1,49 @@
package vehicle
import (
"time"
"github.com/evcc-io/evcc/api"
)
var _ API = (*dummy)(nil)
type dummy struct {
api.Vehicle
}
func (v *dummy) Instance() api.Vehicle {
return v.Vehicle
}
func (v *dummy) Name() string {
return ""
}
// GetMinSoc returns the min soc
func (v *dummy) GetMinSoc() int {
return 0
}
// SetMinSoc sets the min soc
func (v *dummy) SetMinSoc(soc int) {
}
// GetLimitSoc returns the limit soc
func (v *dummy) GetLimitSoc() int {
return 0
}
// SetLimitSoc sets the limit soc
func (v *dummy) SetLimitSoc(soc int) {
}
// GetPlanSoc returns the charge plan soc
func (v *dummy) GetPlanSoc() (time.Time, int) {
return time.Time{}, 0
}
// SetPlanSoc sets the charge plan soc
func (v *dummy) SetPlanSoc(ts time.Time, soc int) error {
return nil
}

145
core/vehicle/mock.go Normal file
View file

@ -0,0 +1,145 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/evcc-io/evcc/core/vehicle (interfaces: API)
// Package vehicle is a generated GoMock package.
package vehicle
import (
reflect "reflect"
time "time"
api "github.com/evcc-io/evcc/api"
gomock "github.com/golang/mock/gomock"
)
// MockAPI is a mock of API interface.
type MockAPI struct {
ctrl *gomock.Controller
recorder *MockAPIMockRecorder
}
// MockAPIMockRecorder is the mock recorder for MockAPI.
type MockAPIMockRecorder struct {
mock *MockAPI
}
// NewMockAPI creates a new mock instance.
func NewMockAPI(ctrl *gomock.Controller) *MockAPI {
mock := &MockAPI{ctrl: ctrl}
mock.recorder = &MockAPIMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockAPI) EXPECT() *MockAPIMockRecorder {
return m.recorder
}
// GetLimitSoc mocks base method.
func (m *MockAPI) GetLimitSoc() int {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetLimitSoc")
ret0, _ := ret[0].(int)
return ret0
}
// GetLimitSoc indicates an expected call of GetLimitSoc.
func (mr *MockAPIMockRecorder) GetLimitSoc() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLimitSoc", reflect.TypeOf((*MockAPI)(nil).GetLimitSoc))
}
// GetMinSoc mocks base method.
func (m *MockAPI) GetMinSoc() int {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetMinSoc")
ret0, _ := ret[0].(int)
return ret0
}
// GetMinSoc indicates an expected call of GetMinSoc.
func (mr *MockAPIMockRecorder) GetMinSoc() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMinSoc", reflect.TypeOf((*MockAPI)(nil).GetMinSoc))
}
// GetPlanSoc mocks base method.
func (m *MockAPI) GetPlanSoc() (time.Time, int) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPlanSoc")
ret0, _ := ret[0].(time.Time)
ret1, _ := ret[1].(int)
return ret0, ret1
}
// GetPlanSoc indicates an expected call of GetPlanSoc.
func (mr *MockAPIMockRecorder) GetPlanSoc() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanSoc", reflect.TypeOf((*MockAPI)(nil).GetPlanSoc))
}
// Instance mocks base method.
func (m *MockAPI) Instance() api.Vehicle {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Instance")
ret0, _ := ret[0].(api.Vehicle)
return ret0
}
// Instance indicates an expected call of Instance.
func (mr *MockAPIMockRecorder) Instance() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Instance", reflect.TypeOf((*MockAPI)(nil).Instance))
}
// Name mocks base method.
func (m *MockAPI) Name() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Name")
ret0, _ := ret[0].(string)
return ret0
}
// Name indicates an expected call of Name.
func (mr *MockAPIMockRecorder) Name() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockAPI)(nil).Name))
}
// SetLimitSoc mocks base method.
func (m *MockAPI) SetLimitSoc(arg0 int) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SetLimitSoc", arg0)
}
// SetLimitSoc indicates an expected call of SetLimitSoc.
func (mr *MockAPIMockRecorder) SetLimitSoc(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLimitSoc", reflect.TypeOf((*MockAPI)(nil).SetLimitSoc), arg0)
}
// SetMinSoc mocks base method.
func (m *MockAPI) SetMinSoc(arg0 int) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SetMinSoc", arg0)
}
// SetMinSoc indicates an expected call of SetMinSoc.
func (mr *MockAPIMockRecorder) SetMinSoc(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetMinSoc", reflect.TypeOf((*MockAPI)(nil).SetMinSoc), arg0)
}
// SetPlanSoc mocks base method.
func (m *MockAPI) SetPlanSoc(arg0 time.Time, arg1 int) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetPlanSoc", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// SetPlanSoc indicates an expected call of SetPlanSoc.
func (mr *MockAPIMockRecorder) SetPlanSoc(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanSoc", reflect.TypeOf((*MockAPI)(nil).SetPlanSoc), arg0, arg1)
}

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

@ -0,0 +1,32 @@
package vehicle
import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
)
func device(vehicle api.Vehicle) config.Device[api.Vehicle] {
for _, dev := range config.Vehicles().Devices() {
if dev.Instance() == vehicle {
return dev
}
}
return nil
}
func Settings(log *util.Logger, v api.Vehicle) API {
if dev := device(v); dev != nil {
return Adapter(log, dev)
}
return &dummy{v}
}
// Adapter creates a vehicle API adapter
func Adapter(log *util.Logger, dev config.Device[api.Vehicle]) API {
return &adapter{
log: log,
name: dev.Config().Name,
Vehicle: dev.Instance(),
}
}

1
go.mod
View file

@ -21,7 +21,6 @@ require (
github.com/benbjohnson/clock v1.3.5
github.com/bogosj/tesla v1.3.1
github.com/cenkalti/backoff/v4 v4.2.1
github.com/cjrd/allocate v0.0.0-20220510215731-986f24f0fb18
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
github.com/containrrr/shoutrrr v0.8.0
github.com/coreos/go-oidc/v3 v3.7.0

2
go.sum
View file

@ -129,8 +129,6 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cjrd/allocate v0.0.0-20220510215731-986f24f0fb18 h1:zcZS1zJnskmXLPfVITaw/mHtBGu3Mo2vp46gCBxA3V4=
github.com/cjrd/allocate v0.0.0-20220510215731-986f24f0fb18/go.mod h1:xCdduY82QBtGJbFbch7ShY3ltvP4/a+1kBh3HzGTaEk=
github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 h1:tuijfIjZyjZaHq9xDUh0tNitwXshJpbLkqMOJv4H3do=

View file

@ -388,8 +388,8 @@ func (s *SEMP) deviceInfo(id int, lp loadpoint.API) DeviceInfo {
OptionalEnergy: true,
},
Characteristics: Characteristics{
MinPowerConsumption: int(lp.GetMinPower()),
MaxPowerConsumption: int(lp.GetMaxPower()),
MinPowerConsumption: int(lp.EffectiveMinPower()),
MaxPowerConsumption: int(lp.EffectiveMaxPower()),
},
}
@ -465,8 +465,8 @@ func (s *SEMP) planningRequest(id int, lp loadpoint.API) (res PlanningRequest) {
minEnergy = 0
}
maxPowerConsumption := int(lp.GetMaxPower())
minPowerConsumption := int(lp.GetMinPower())
maxPowerConsumption := int(lp.EffectiveMaxPower())
minPowerConsumption := int(lp.EffectiveMinPower())
if mode == api.ModeNow {
minPowerConsumption = maxPowerConsumption
}

View file

@ -119,7 +119,6 @@ modalTitle = "¿Segur que vols tornar començar?"
vehicles = "Estacionament"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "Arribades"
departureTab = "Sortides"
modalTitle = "Plà de càrrega"

View file

@ -119,7 +119,6 @@ modalTitle = "Opravdu chcete restartovat?"
vehicles = "Parkoviště"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "Příchod"
departureTab = "Odchod"
modalTitle = "Plán nabíjení"

View file

@ -164,7 +164,6 @@ modalTitle = "Er du sikker på, at du vil genstarte?"
vehicles = "Parkering"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "Ankomst"
departureTab = "Afgang"
modalTitle = "Lade plan"
@ -234,7 +233,6 @@ logout = "Log ud"
[main.targetCharge]
activate = "Tænd"
activeLabel = "{time}"
co2Limit = "CO₂ grænse på {co2}"
costLimitIgnore = "Den konfigurerede {limit} vil blive ignoreret i denne periode."
descriptionEnergy = "Hvornår skal {targetEnergy} være sendt til køretøjet?"
@ -248,7 +246,6 @@ planPeriodValue = "{start} til {end}"
planUnknown = "Ikke kendt endnu"
priceLimit = "pris grænse på {price}"
setTargetTime = "ingen"
targetEnergyRequired = "Du skal også indstille en energigrænse"
targetIsInThePast = "Vælg et tidspunkt i fremtiden, Marty."
targetIsTooFarInTheFuture = "Vi justerer planen så snart vi ved mere om fremtiden."
title = "Planlagt tid"

View file

@ -164,7 +164,6 @@ modalTitle = "Sicher, dass du neu starten möchtest?"
vehicles = "Parkplatz"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "Ankunft"
departureTab = "Abfahrt"
modalTitle = "Ladeplanung"
@ -208,6 +207,10 @@ disclaimerText = "Änderungen gehen nach einem Neustart des Servers verloren."
title = "Einstellungen {0}"
vehicle = "Fahrzeug"
[main.loadpointSettings.limitSoc]
description = "Dieses Limit wird verwendet, wenn das Fahrzeug angeschlossen wird."
label = "Standard Ladelimit"
[main.loadpointSettings.maxCurrent]
label = "Max. Ladestrom"
@ -244,14 +247,16 @@ descriptionEnergy = "Bis wann sollen {targetEnergy} ins Fahrzeug geladen sein?"
descriptionSoc = "Wann soll das Fahrzeug auf {targetSoc}% geladen sein?"
inactiveLabel = "Zielzeit"
onlyInPvMode = "Ladeplanung ist nur im PV-Modus aktiv."
planDescription = "Gib eine Abfahrtszeit an, und evcc lädt dein Fahrzeug so kostengünstig und umweltfreundlich wie möglich."
planDuration = "Ladedauer"
planPeriodLabel = "Zeitraum"
planPeriodValue = "{start} bis {end}"
planUnknown = "noch unbekannt"
priceLimit = "Preisgrenze von {price}"
remove = "Entfernen"
setPlan = "Ladeplan festlegen"
setTargetTime = "keine"
targetEnergyRequired = "Damit die Ladeplanung funktioniert muss ein Ladeziel gesetzt sein."
targetIsAboveLimit = "Das Ladeziel {goal} liegt über dem konfigurierten Limit {limit}. Erhöhe das Limit, um das Ziel zu erreichen."
targetIsInThePast = "Wähle einen Zeitpunkt in der Zukunft, Marty."
targetIsTooFarInTheFuture = "Wir passen den Plan an, sobald wir mehr über die Zukunft wissen."
title = "Zielzeit"
@ -269,7 +274,7 @@ timeRange = "{day} {range} Uhr"
unknownPrice = "noch unbekannt"
[main.targetEnergy]
label = "Ladeziel"
label = "Ladelimit"
noLimit = "keins"
[main.vehicle]
@ -279,7 +284,7 @@ detectionActive = "Fahrzeugerkennung läuft …"
fallbackName = "Fahrzeug"
moreActions = "Weitere Aktionen"
none = "Kein Fahrzeug"
targetSoc = "Ladeziel"
targetSoc = "Ladelimit"
temp = "Temp."
tempLimit = "Zieltemp."
unknown = "Gastfahrzeug"

View file

@ -161,11 +161,14 @@ modalTitle = "Are you sure you want to restart?"
vehicles = "Parking"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "Arrival"
day = "Day"
departureTab = "Departure"
goal = "Charging goal"
modalTitle = "Charging Plan"
none = "none"
remove = "Remove"
time = "Time"
title = "Plan"
titleMinSoc = "Min charge"
titleTargetCharge = "Departure"
@ -205,6 +208,10 @@ disclaimerText = "Changes will be lost after the server is restarted."
title = "Settings {0}"
vehicle = "Vehicle"
[main.loadpointSettings.limitSoc]
description = "Charging limit that is used when this vehicle is connected."
label = "Default limit"
[main.loadpointSettings.maxCurrent]
label = "Max. Current"
@ -241,14 +248,16 @@ descriptionEnergy = "Until when should {targetEnergy} be loaded into the vehicle
descriptionSoc = "When should the vehicle be charged to {targetSoc}%?"
inactiveLabel = "Target time"
onlyInPvMode = "Charging plan only works in solar mode."
planDescription = "Enter your departure time, and evcc will charge your vehicle as cost-effectively or environmentally friendly as possible."
planDuration = "Charging time"
planPeriodLabel = "Period"
planPeriodValue = "{start} to {end}"
planUnknown = "not known yet"
priceLimit = "price limit of {price}"
remove = "Remove"
setPlan = "Set a charging plan"
setTargetTime = "none"
targetEnergyRequired = "You also need to set an energy limit."
targetIsAboveLimit = "Charging goal {goal} is above the configured limit {limit}. You must increase the limit to reach the goal."
targetIsInThePast = "Pick a time in the future, Marty."
targetIsTooFarInTheFuture = "We will adjust the plan as soon as we know more about the future."
title = "Target Time"

View file

@ -172,7 +172,6 @@ modalTitle = "¿Seguro que quieres volver a empezar?"
vehicles = "Estacionamiento"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "Llegadas"
departureTab = "Salidas"
modalTitle = "Plan de carga"
@ -246,7 +245,6 @@ logout = "cerrar sesión"
[main.targetCharge]
activate = "Activar"
activeLabel = "{time}"
co2Limit = "Límite de CO₂ del {co2}"
costLimitIgnore = "El {limit} configurado será ignorado durante este periodo."
descriptionEnergy = "¿Hasta cuándo deben haberse cargado {targetEnergy} al vehículo?"
@ -261,7 +259,6 @@ planUnknown = "aún no se sabe"
priceLimit = "límite en el precio de {price}"
remove = "retirar"
setTargetTime = "niguno"
targetEnergyRequired = "También hay que fijar un límite de energía"
targetIsInThePast = "Elige un tiempo en el futuro, Marty."
targetIsTooFarInTheFuture = "Ajustaremos el plan en cuanto sepamos más sobre el futuro."
title = "Tiempo objetivo"

View file

@ -164,7 +164,6 @@ modalTitle = "Oletko varma että haluat uudelleen käynnistää?"
vehicles = "Pysäköinti"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "Saapuminen"
departureTab = "Lähtö"
modalTitle = "Lataussuunnitelma"
@ -234,7 +233,6 @@ logout = "kirjaudu ulos"
[main.targetCharge]
activate = "Aktivoi"
activeLabel = "{time}"
co2Limit = "Raja CO₂ / {co2}"
costLimitIgnore = "Määritetty {limit} ohitetaan tänä aikana."
descriptionEnergy = "Mihin mennessä {targetEnergy} tulisi ladata autoon?"
@ -248,7 +246,6 @@ planPeriodValue = "{start} - {end}"
planUnknown = "ei vielä tiedossa"
priceLimit = "{price} hintaraja"
setTargetTime = "ei mitään"
targetEnergyRequired = "Sinun on myös asetettava energia määrälle raja"
targetIsInThePast = "Valitse aika tulevaisuudessa"
targetIsTooFarInTheFuture = "Muokkaamme suunnitelmaa heti kun tiedämme enemmän tulevaisuudesta."
title = "Tavoiteaika"

View file

@ -157,7 +157,6 @@ modalTitle = "Voulez-vous vraiment redémarrer ?"
vehicles = "Place de stationnement"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "Arrivée"
departureTab = "Départ"
modalTitle = "Planification de la charge"
@ -230,7 +229,6 @@ logout = "déconnexion"
[main.targetCharge]
activate = "Activer"
activeLabel = "{time}"
co2Limit = "Limite de CO₂ {co2]"
costLimitIgnore = "La limite configurée {limit} sera ignorée durant cette période."
descriptionEnergy = "Jusqu'à quand le véhicule doit-il être chargé à {targetEnergy}?"
@ -245,7 +243,6 @@ planUnknown = "encore inconnu"
priceLimit = "limite de prix : {price}"
remove = "Ôter"
setTargetTime = "aucun"
targetEnergyRequired = "Vous devez également fixer une limite énergétique."
targetIsInThePast = "Choisis une période dans le futur, Marty."
targetIsTooFarInTheFuture = "Nous ajusterons le plan dès qu'on en saura plus sur le futur."
title = "Temps visé"

View file

@ -165,7 +165,6 @@ modalTitle = "Stvarno želiš ponovo pokrenuti aplikaciju?"
vehicles = "Parkiranje"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "Dolazak"
departureTab = "Odlazak"
modalTitle = "Plan napajanja"
@ -235,7 +234,6 @@ logout = "odjavi se"
[main.targetCharge]
activate = "Aktiviraj"
activeLabel = "{time}"
co2Limit = "CO₂ granica od {co2}"
costLimitIgnore = "Konfigurirani {limit} zanemarit će se tijekom ovog razdoblja."
descriptionEnergy = "Do kada bi {targetEnergy} trebalo napojiti vozilo?"
@ -249,7 +247,6 @@ planPeriodValue = "{start} do {end}"
planUnknown = "još nije poznato"
priceLimit = "ograničenje cijene od {price}"
setTargetTime = "ništa"
targetEnergyRequired = "Moraš postaviti i ograničenje energije."
targetIsInThePast = "Odaberi vrijeme u budućnosti."
targetIsTooFarInTheFuture = "Prilagodit ćemo plan čim budemo znali više o budućnosti."
title = "Ciljano vrijeme"

View file

@ -136,7 +136,6 @@ logout = "log out"
[main.targetCharge]
activate = "Attivare"
activeLabel = "{time}"
descriptionEnergy = "Fino a quando dovrebbero essere {targetEnergy} caricati nel veicolo?"
descriptionSoc = "Quando dovrebbe essere caricato il veicolo al {targetSoc}%?"
inactiveLabel = "Tempo target"

View file

@ -129,7 +129,6 @@ logout = "ausloggen"
[main.targetCharge]
activate = "Aktivéieren"
activeLabel = "{time}"
descriptionEnergy = "Bis wéini soll {targetEnergy} an d'Gefier gelueden ginn?"
descriptionSoc = "Wéini soll d'Gefier op {targetSoc}% gelueden ginn?"
inactiveLabel = "Zilzäit"

View file

@ -172,7 +172,6 @@ modalTitle = "Ar tikrai norite restartuoti?"
vehicles = "Autoparkas"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "Atvykimas"
departureTab = "Išvykimas"
modalTitle = "Įkrovimo planas"
@ -246,7 +245,6 @@ logout = "atsijungti"
[main.targetCharge]
activate = "Aktyvuoti"
activeLabel = "{time}"
co2Limit = "CO₂ riba iš {co2}"
costLimitIgnore = "Šiuo laikotarpiu sukonfigūruotas {limit} bus ignoruojamas."
descriptionEnergy = "Iki kada {targetEnergy} turėtų būti įkrauta į automobilį?"
@ -261,7 +259,6 @@ planUnknown = "dar nežinome"
priceLimit = "kainos limitas {price}"
remove = "Panaikinti"
setTargetTime = "nesuplanuotas"
targetEnergyRequired = "Taipogi turite nustatyti energijos limitą."
targetIsInThePast = "Pasirinkite laiką ateityje."
targetIsTooFarInTheFuture = "Pakoreguosime planą, kai tik gausime naujų duomenų."
title = "Planinis Įkrovimas"

View file

@ -157,7 +157,6 @@ modalTitle = "Ben je zeker dat je wil herstarten?"
vehicles = "Parking"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "Aankomst"
departureTab = "Vertrek"
modalTitle = "Laad Plan"
@ -230,7 +229,6 @@ logout = "afmelden"
[main.targetCharge]
activate = "Activeren"
activeLabel = "{time}"
co2Limit = "CO₂ limiet van {co2}"
costLimitIgnore = "De geconfigureerde {limit} zal genegeerd worden tijdens deze periode."
descriptionEnergy = "Tegen wanneer moet {targetEnergy} in het voertuig geladen zijn?"
@ -245,7 +243,6 @@ planUnknown = "nog niet bekend"
priceLimit = "prijslimiet van {price}"
remove = "Verwijder"
setTargetTime = "geen"
targetEnergyRequired = "Je dient ook een energie limiet in te stellen"
targetIsInThePast = "Kies een tijd in de toekomst, Marty."
targetIsTooFarInTheFuture = "We passen het plan aan zodra we meer weten over de toekomst."
title = "Doeltijd"

View file

@ -157,7 +157,6 @@ modalTitle = "Er du sikker på at du vil utføre omstart?"
vehicles = "Parkering"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "Ankomst"
departureTab = "Avgang"
modalTitle = "Ladeplan"
@ -227,7 +226,6 @@ logout = "logg ut"
[main.targetCharge]
activate = "Aktiver"
activeLabel = "{time}"
co2Limit = "CO₂-grense på {co2}"
costLimitIgnore = "Ser bort fra oppsatt grense på {limit} i løpet av denne perioden."
descriptionEnergy = "Når skal {targetEnergy} være på kjøretøyet?"
@ -241,7 +239,6 @@ planPeriodValue = "{start} til {end}"
planUnknown = "ikke kjent enda"
priceLimit = "prisgrense på {price}"
setTargetTime = "ingen"
targetEnergyRequired = "Du må også sette en energigrense."
targetIsInThePast = "Velg et tidspunkt i fremtiden, Marty."
targetIsTooFarInTheFuture = "Planen vil bli justert så snart mer info tilkommer."
title = "Mål-tid"

View file

@ -169,7 +169,6 @@ modalTitle = "Czy na pewno chcesz uruchomić ponownie?"
vehicles = "Garaż"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "Przyjazd"
departureTab = "Wyjazd"
modalTitle = "Plan ładowania"
@ -242,7 +241,6 @@ logout = "wyloguj"
[main.targetCharge]
activate = "Aktywować"
activeLabel = "{time}"
co2Limit = "Limit CO₂ z {co2}"
costLimitIgnore = "Skonfigurowany {limit} będzie ignorowany w tym okresie."
descriptionEnergy = "Do kiedy należy załadować {targetEnergy} do pojazdu?"
@ -257,7 +255,6 @@ planUnknown = "jeszcze nie wiadomo"
priceLimit = "limit ceny {cena}"
remove = "Usuń"
setTargetTime = "brak"
targetEnergyRequired = "Musisz także ustawić limit energii."
targetIsInThePast = "Wybierz czas w przyszłości."
targetIsTooFarInTheFuture = "Dostosujemy plan, gdy tylko dowiemy się więcej o przyszłości."
title = "Czas Docelowy"

View file

@ -169,7 +169,6 @@ modalTitle = "Tens a certeza que queres reiniciar"
vehicles = "Estacionamento"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "Chegada"
departureTab = "Partida"
modalTitle = "Plano de carregamento"
@ -242,7 +241,6 @@ logout = "encerrar sessão"
[main.targetCharge]
activate = "Ativar"
activeLabel = "{time}"
co2Limit = "Limite de CO₂ de {co2}"
costLimitIgnore = "O {limit} configurado será ignorado durante este período."
descriptionEnergy = "Até quando {targetEnergy} deve ser carregado no veículo?"
@ -257,7 +255,6 @@ planUnknown = "desconhecido"
priceLimit = "limite de preço de {preço}"
remove = "Remover"
setTargetTime = "sem"
targetEnergyRequired = "Você também precisa definir um limite de energia."
targetIsInThePast = "Escolha um momento no futuro, Marty."
targetIsTooFarInTheFuture = "Vamos ajustar o plano assim que soubermos mais sobre o futuro."
title = "Tempo alvo"

View file

@ -111,7 +111,6 @@ restartDescription = "Ai incercat sa inchizi si sa deschizi din nou? "
vehicles = "Parcare"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "Ajungere"
departureTab = "Plecare"
modalTitle = "Plan de incarcare"
@ -177,7 +176,6 @@ logout = "deconectează"
[main.targetCharge]
activate = "Activează"
activeLabel = "{time}"
co2Limit = "limita CO₂ de {co2}"
costLimitIgnore = "{limit} configurata de va fi ingnorata in acest timp."
descriptionEnergy = "Pâna când ar trebui ca {targetEnergy} să fie încărcată in vehicul?"

View file

@ -122,7 +122,6 @@ logout = "выйти"
[main.targetCharge]
activate = "Активировать"
activeLabel = "{time}"
descriptionSoc = "Когда транспортное средство следует зарядить до {targetSoc}%?"
inactiveLabel = "Запланированное время"
modalTitle = "Установить запланированное время"

View file

@ -152,7 +152,6 @@ modalTitle = "Ali ste prepričani, da želite znova zagnati?"
vehicles = "Parkiranje"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "Prihod"
departureTab = "Odhod"
modalTitle = "Načrt polnjenja"
@ -222,7 +221,6 @@ logout = "odjava"
[main.targetCharge]
activate = "Aktiviraj"
activeLabel = "{time}"
co2Limit = "Omejitev CO₂ {co2}"
costLimitIgnore = "V tem obdobju se nastavljena {limit} ne bo upoštevala."
descriptionEnergy = "Do kdaj želite da se vozilo napolni za {targetEnergy}?"
@ -236,7 +234,6 @@ planPeriodValue = "{start} do {end}"
planUnknown = "še ni znano"
priceLimit = "cenovna omejitev {price}"
setTargetTime = "brez"
targetEnergyRequired = "Postaviti morate tudi mejo energije."
targetIsInThePast = "Izberi čas v prihodnosti, Marty."
targetIsTooFarInTheFuture = "Načrt bomo prilagodili takoj, ko bomo izvedeli več o novi funkcionalnosti."
title = "Ciljni čas"

View file

@ -114,7 +114,6 @@ modalTitle = "Är du säker på att du vill starta om?"
vehicles = "Parkering"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "Ankomst"
departureTab = "Avfärd"
modalTitle = "Laddplan"
@ -180,7 +179,6 @@ logout = "logga ut"
[main.targetCharge]
activate = "Aktivera"
activeLabel = "{time}"
co2Limit = "CO₂ gräns av {co2}"
costLimitIgnore = "Den inställda nivån {limit} kommer att ignoreras den här perioden."
descriptionEnergy = "Fram till när ska {targetEnergy} lastas in i fordonet?"

View file

@ -153,7 +153,6 @@ modalTitle = "Ви впевнені, що хочете перезапустит
vehicles = "Паркування"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "Прибуття"
departureTab = "Від'їзд"
modalTitle = "Тарифний план"
@ -223,7 +222,6 @@ logout = "вийти"
[main.targetCharge]
activate = "Активувати"
activeLabel = "{time}"
co2Limit = "Ліміт CO₂ для {co2}"
costLimitIgnore = "Налаштований {ліміт} буде ігноруватися протягом цього періоду."
description = "Коли транспортний засіб повинен бути заряджений на {targetSoc}%?"
@ -238,7 +236,6 @@ planPeriodValue = "{start} до {end}"
planUnknown = "ще не відомо"
priceLimit = "ліміт ціни {price}"
setTargetTime = "жодного"
targetEnergyRequired = "Також потрібно встановити ліміт енергії"
targetIsInThePast = "Виберіть час у майбутньому."
targetIsTooFarInTheFuture = "Ми скоригуємо план, як тільки дізнаємося більше про майбутнє."
title = "Запланований Час"

View file

@ -164,7 +164,6 @@ modalTitle = "你确定要重启吗?"
vehicles = "停车中"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "抵达"
departureTab = "离开"
modalTitle = "充电计划"
@ -246,7 +245,6 @@ planPeriodValue = "{start} - {end}"
planUnknown = "还不知道"
priceLimit = "价格限制 {price}"
setTargetTime = "无"
targetEnergyRequired = "你还需要设置一个能量限制."
targetIsInThePast = "选择将来的时间,马蒂."
targetIsTooFarInTheFuture = "一旦我们对未来有了更多的了解,我们就会调整计划."
title = "目标时间"

View file

@ -14,6 +14,7 @@ var acceptable = []string{
"mqtt not configured",
"not a SunSpec device",
"missing credentials", // sockets
"power: timeout", // sockets
"missing password", // Powerwall
"connect: no route to host",
"connect: connection refused",

View file

@ -103,6 +103,12 @@
"capacity": {
"type": "integer"
},
"phases": {
"type": "integer"
},
"identifiers": {
"type": "array"
},
"user": {
"type": "string"
},
@ -121,10 +127,7 @@
"mode": {
"$ref": "#/definitions/mode"
},
"minSoc": {
"type": "integer"
},
"targetSoc": {
"priority": {
"type": "integer"
},
"minCurrent": {
@ -165,15 +168,6 @@
"minItems": 1,
"uniqueItems": true
},
"pvs": {
"description": "PV inverters/meters",
"type": "array",
"items": {
"type": "string"
},
"minItems": 1,
"uniqueItems": true
},
"battery": {
"description": "Home battery (1 or more)",
"type": [
@ -186,15 +180,6 @@
"minItems": 1,
"uniqueItems": true
},
"batteries": {
"description": "Home batteries",
"type": "array",
"items": {
"type": "string"
},
"minItems": 1,
"uniqueItems": true
},
"smart": {
"type": "string"
}
@ -203,20 +188,8 @@
"residualPower": {
"type": "number"
},
"prioritySoc": {
"type": "number"
},
"bufferSoc": {
"type": "number"
},
"bufferStartSoc": {
"type": "number"
},
"maxGridSupplyWhileBatteryCharging": {
"type": "number"
},
"autoChargeCostLimit": {
"type": "number"
}
}
},

Some files were not shown because too many files have changed in this diff Show more