Battery control: add api (#10815)
* Battery control: add api * wip * wip * add battery discharge UI and state * Publish controllable * Reset to normal when disabling * minor * wip * wip * Revert mode on shutdown * refactored tests, renamed method in site_battery (#10859) * Fix linter * only show option if controllable; not supported message instead --------- Co-authored-by: Michael Geers <michael@geers.tv> Co-authored-by: Michael Heß <GrimmiMeloni@users.noreply.github.com>
This commit is contained in:
parent
c55aec281f
commit
ed9e5f398a
13 changed files with 389 additions and 289 deletions
|
|
@ -22,200 +22,232 @@
|
|||
aria-label="Close"
|
||||
></button>
|
||||
</div>
|
||||
<div
|
||||
class="modal-body d-flex justify-content-between align-items-center align-items-sm-start pb-2 flex-column flex-sm-row-reverse"
|
||||
>
|
||||
<div class="battery mb-5 mb-sm-3 me-5 me-sm-0">
|
||||
<div class="batteryLimits">
|
||||
<label
|
||||
class="bufferSoc p-2 end-0"
|
||||
role="button"
|
||||
:class="{
|
||||
'bufferSoc--hidden':
|
||||
selectedBufferSoc === selectedPrioritySoc,
|
||||
}"
|
||||
:style="{ top: `${topHeight}%` }"
|
||||
>
|
||||
<select
|
||||
:value="selectedBufferSoc"
|
||||
class="custom-select"
|
||||
@change="changeBufferSoc"
|
||||
<div class="modal-body">
|
||||
<div
|
||||
class="d-flex justify-content-between align-items-center align-items-sm-start pb-2 flex-column flex-sm-row-reverse"
|
||||
>
|
||||
<div class="battery mb-5 mb-sm-3 me-5 me-sm-0">
|
||||
<div class="batteryLimits">
|
||||
<label
|
||||
class="bufferSoc p-2 end-0"
|
||||
role="button"
|
||||
:class="{
|
||||
'bufferSoc--hidden':
|
||||
selectedBufferSoc === selectedPrioritySoc,
|
||||
}"
|
||||
:style="{ top: `${topHeight}%` }"
|
||||
>
|
||||
<option
|
||||
v-for="{ value, name, disabled } in bufferOptions"
|
||||
:key="value"
|
||||
:value="value"
|
||||
:disabled="disabled"
|
||||
<select
|
||||
:value="selectedBufferSoc"
|
||||
class="custom-select"
|
||||
@change="changeBufferSoc"
|
||||
>
|
||||
{{ name }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="text-decoration-underline text-nowrap pe-none">
|
||||
{{ fmtSoc(selectedBufferSoc) }}
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
class="prioritySoc p-2 end-0"
|
||||
role="button"
|
||||
:style="{ top: `${100 - bottomHeight}%` }"
|
||||
>
|
||||
<select
|
||||
:value="selectedPrioritySoc"
|
||||
class="custom-select"
|
||||
@change="changePrioritySoc"
|
||||
<option
|
||||
v-for="{ value, name, disabled } in bufferOptions"
|
||||
:key="value"
|
||||
:value="value"
|
||||
:disabled="disabled"
|
||||
>
|
||||
{{ name }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="text-decoration-underline text-nowrap pe-none">
|
||||
{{ fmtSoc(selectedBufferSoc) }}
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
class="prioritySoc p-2 end-0"
|
||||
role="button"
|
||||
:style="{ top: `${100 - bottomHeight}%` }"
|
||||
>
|
||||
<option
|
||||
v-for="{ value, name, disabled } in priorityOptions"
|
||||
:key="value"
|
||||
:value="value"
|
||||
:disabled="disabled"
|
||||
<select
|
||||
:value="selectedPrioritySoc"
|
||||
class="custom-select"
|
||||
@change="changePrioritySoc"
|
||||
>
|
||||
{{ name }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="text-decoration-underline text-nowrap pe-none">
|
||||
{{ fmtSoc(selectedPrioritySoc) }}
|
||||
</span>
|
||||
</label>
|
||||
<option
|
||||
v-for="{ value, name, disabled } in priorityOptions"
|
||||
:key="value"
|
||||
:value="value"
|
||||
:disabled="disabled"
|
||||
>
|
||||
{{ name }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="text-decoration-underline text-nowrap pe-none">
|
||||
{{ fmtSoc(selectedPrioritySoc) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div
|
||||
class="bg-dark-green progress-bar text-light align-items-center"
|
||||
role="button"
|
||||
:style="{ height: `${topHeight}%` }"
|
||||
@click="toggleBufferStart"
|
||||
>
|
||||
<shopicon-regular-lightning
|
||||
size="m"
|
||||
class="icon"
|
||||
:style="iconStyle(topHeight)"
|
||||
></shopicon-regular-lightning>
|
||||
</div>
|
||||
<div
|
||||
class="bg-darker-green progress-bar text-light align-items-center"
|
||||
:style="{ height: `${middleHeight}%` }"
|
||||
>
|
||||
<shopicon-regular-car3
|
||||
size="m"
|
||||
class="icon"
|
||||
:style="iconStyle(middleHeight)"
|
||||
></shopicon-regular-car3>
|
||||
</div>
|
||||
<div
|
||||
class="bg-darkest-green progress-bar text-light align-items-center"
|
||||
:style="{ height: `${bottomHeight}%` }"
|
||||
>
|
||||
<shopicon-regular-home
|
||||
size="m"
|
||||
class="icon"
|
||||
:style="iconStyle(bottomHeight)"
|
||||
></shopicon-regular-home>
|
||||
</div>
|
||||
<div
|
||||
class="batterySoc ps-0 bg-white pe-none"
|
||||
:style="{ top: `${100 - batterySoc}%` }"
|
||||
></div>
|
||||
<div
|
||||
class="bufferStartIndicator pe-none"
|
||||
:class="{
|
||||
'bufferStartIndicator--hidden':
|
||||
!selectedBufferStartSoc ||
|
||||
selectedBufferSoc === 100,
|
||||
}"
|
||||
:style="{ top: `${bufferStartTop}%` }"
|
||||
>
|
||||
<div class="bufferStartIndicator__left"></div>
|
||||
<div class="bufferStartIndicator__right"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div
|
||||
class="bg-dark-green progress-bar text-light align-items-center"
|
||||
role="button"
|
||||
:style="{ height: `${topHeight}%` }"
|
||||
@click="toggleBufferStart"
|
||||
>
|
||||
<div class="me-sm-4">
|
||||
<p>
|
||||
{{ $t("batterySettings.batteryLevel") }}:
|
||||
<strong>{{ fmtSoc(batterySoc) }}</strong>
|
||||
<small
|
||||
v-for="(line, index) in batteryDetails"
|
||||
:key="index"
|
||||
class="d-block"
|
||||
>
|
||||
{{ line }}
|
||||
</small>
|
||||
</p>
|
||||
<p>{{ $t("batterySettings.legendTitle") }}</p>
|
||||
<p class="d-flex">
|
||||
<shopicon-regular-lightning
|
||||
size="m"
|
||||
class="icon"
|
||||
:style="iconStyle(topHeight)"
|
||||
size="s"
|
||||
class="flex-shrink-0 me-2"
|
||||
></shopicon-regular-lightning>
|
||||
</div>
|
||||
<div
|
||||
class="bg-darker-green progress-bar text-light align-items-center"
|
||||
:style="{ height: `${middleHeight}%` }"
|
||||
>
|
||||
<span class="d-block">
|
||||
{{ $t("batterySettings.legendTopName") }}
|
||||
<small v-if="selectedBufferSoc == 100" class="d-block">
|
||||
{{ $t("batterySettings.legendTopSubline") }}
|
||||
</small>
|
||||
<small v-else class="d-block">
|
||||
{{ $t("batterySettings.legendTopAutostart") }}
|
||||
<label
|
||||
for="bufferStartSelect"
|
||||
class="position-relative d-block"
|
||||
>
|
||||
<select
|
||||
id="bufferStartSelect"
|
||||
class="custom-select"
|
||||
@change="changeBufferStart"
|
||||
>
|
||||
<option
|
||||
v-for="option in [
|
||||
'never',
|
||||
'full',
|
||||
'medium',
|
||||
'low',
|
||||
]"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
`batterySettings.bufferStart.${option}`
|
||||
)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<span class="text-decoration-underline">
|
||||
{{
|
||||
$t(
|
||||
`batterySettings.bufferStart.${bufferStartOption}`
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</label>
|
||||
</small>
|
||||
</span>
|
||||
</p>
|
||||
<p class="d-flex">
|
||||
<shopicon-regular-car3
|
||||
size="m"
|
||||
class="icon"
|
||||
:style="iconStyle(middleHeight)"
|
||||
size="s"
|
||||
class="flex-shrink-0 me-2"
|
||||
></shopicon-regular-car3>
|
||||
</div>
|
||||
<div
|
||||
class="bg-darkest-green progress-bar text-light align-items-center"
|
||||
:style="{ height: `${bottomHeight}%` }"
|
||||
>
|
||||
<span class="d-block">
|
||||
{{ $t("batterySettings.legendMiddleName") }}
|
||||
<small class="d-block">
|
||||
{{ $t("batterySettings.legendMiddleSubline") }}
|
||||
</small>
|
||||
</span>
|
||||
</p>
|
||||
<p class="d-flex">
|
||||
<shopicon-regular-home
|
||||
size="m"
|
||||
class="icon"
|
||||
:style="iconStyle(bottomHeight)"
|
||||
size="s"
|
||||
class="flex-shrink-0 me-2"
|
||||
></shopicon-regular-home>
|
||||
</div>
|
||||
<div
|
||||
class="batterySoc ps-0 bg-white pe-none"
|
||||
:style="{ top: `${100 - batterySoc}%` }"
|
||||
></div>
|
||||
<div
|
||||
class="bufferStartIndicator pe-none"
|
||||
:class="{
|
||||
'bufferStartIndicator--hidden':
|
||||
!selectedBufferStartSoc || selectedBufferSoc === 100,
|
||||
}"
|
||||
:style="{ top: `${bufferStartTop}%` }"
|
||||
>
|
||||
<div class="bufferStartIndicator__left"></div>
|
||||
<div class="bufferStartIndicator__right"></div>
|
||||
</div>
|
||||
<span class="d-block">
|
||||
{{ $t("batterySettings.legendBottomName") }}
|
||||
<small class="d-block">
|
||||
{{ $t("batterySettings.legendBottomSubline") }}
|
||||
</small>
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<small>
|
||||
{{ $t("batterySettings.note") }}
|
||||
</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="me-sm-4">
|
||||
<p>
|
||||
{{ $t("batterySettings.batteryLevel") }}:
|
||||
<strong>{{ fmtSoc(batterySoc) }}</strong>
|
||||
<small
|
||||
v-for="(line, index) in batteryDetails"
|
||||
:key="index"
|
||||
class="d-block"
|
||||
>
|
||||
{{ line }}
|
||||
</small>
|
||||
</p>
|
||||
<p>{{ $t("batterySettings.legendTitle") }}</p>
|
||||
<p class="d-flex">
|
||||
<shopicon-regular-lightning
|
||||
size="s"
|
||||
class="flex-shrink-0 me-2"
|
||||
></shopicon-regular-lightning>
|
||||
<span class="d-block">
|
||||
{{ $t("batterySettings.legendTopName") }}
|
||||
<small v-if="selectedBufferSoc == 100" class="d-block">
|
||||
{{ $t("batterySettings.legendTopSubline") }}
|
||||
</small>
|
||||
<small v-else class="d-block">
|
||||
{{ $t("batterySettings.legendTopAutostart") }}
|
||||
<label
|
||||
for="bufferStartSelect"
|
||||
class="position-relative d-block"
|
||||
>
|
||||
<select
|
||||
id="bufferStartSelect"
|
||||
class="custom-select"
|
||||
@change="changeBufferStart"
|
||||
>
|
||||
<option
|
||||
v-for="option in [
|
||||
'never',
|
||||
'full',
|
||||
'medium',
|
||||
'low',
|
||||
]"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{
|
||||
$t(`batterySettings.bufferStart.${option}`)
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
<span class="text-decoration-underline">
|
||||
{{
|
||||
$t(
|
||||
`batterySettings.bufferStart.${bufferStartOption}`
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<div v-if="$hiddenFeatures()">
|
||||
<FormRow
|
||||
v-if="controllable"
|
||||
id="batteryDischargeControl"
|
||||
:label="`${$t('batterySettings.control')}`"
|
||||
>
|
||||
<div class="form-check form-switch col-form-label">
|
||||
<input
|
||||
id="batteryDischargeControl"
|
||||
:checked="batteryDischargeControl"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
@change="changeDischargeControl"
|
||||
/>
|
||||
<div class="form-check-label">
|
||||
<label for="batteryDischargeControl">
|
||||
{{ $t("batterySettings.discharge") }}
|
||||
<span title="experimental">🧪</span>
|
||||
</label>
|
||||
</small>
|
||||
</span>
|
||||
</p>
|
||||
<p class="d-flex">
|
||||
<shopicon-regular-car3
|
||||
size="s"
|
||||
class="flex-shrink-0 me-2"
|
||||
></shopicon-regular-car3>
|
||||
<span class="d-block">
|
||||
{{ $t("batterySettings.legendMiddleName") }}
|
||||
<small class="d-block">
|
||||
{{ $t("batterySettings.legendMiddleSubline") }}
|
||||
</small>
|
||||
</span>
|
||||
</p>
|
||||
<p class="d-flex">
|
||||
<shopicon-regular-home
|
||||
size="s"
|
||||
class="flex-shrink-0 me-2"
|
||||
></shopicon-regular-home>
|
||||
<span class="d-block">
|
||||
{{ $t("batterySettings.legendBottomName") }}
|
||||
<small class="d-block">
|
||||
{{ $t("batterySettings.legendBottomSubline") }}
|
||||
</small>
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<small>
|
||||
{{ $t("batterySettings.note") }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</FormRow>
|
||||
<p v-else>
|
||||
<small>{{ $t("batterySettings.notControllable") }}</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -230,18 +262,21 @@ import "@h2d2/shopicons/es/regular/lightning";
|
|||
import "@h2d2/shopicons/es/regular/car3";
|
||||
import "@h2d2/shopicons/es/regular/home";
|
||||
import formatter from "../mixins/formatter";
|
||||
import FormRow from "./FormRow.vue";
|
||||
|
||||
import api from "../api";
|
||||
|
||||
export default {
|
||||
name: "BatterySettingsModal",
|
||||
components: { FormRow },
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
bufferSoc: Number,
|
||||
prioritySoc: Number,
|
||||
batterySoc: Number,
|
||||
bufferStartSoc: Number,
|
||||
battery: { type: Array },
|
||||
batteryDischargeControl: Boolean,
|
||||
battery: { type: Array, default: () => [] },
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
|
|
@ -263,6 +298,9 @@ export default {
|
|||
}
|
||||
return options;
|
||||
},
|
||||
controllable() {
|
||||
return this.battery.some(({ controllable }) => controllable);
|
||||
},
|
||||
bufferOptions() {
|
||||
const options = [];
|
||||
for (let i = 100; i >= 5; i -= 5) {
|
||||
|
|
@ -428,6 +466,13 @@ export default {
|
|||
fmtSoc(soc) {
|
||||
return `${Math.round(soc)}%`;
|
||||
},
|
||||
async changeDischargeControl(e) {
|
||||
try {
|
||||
await api.post(`batterydischargecontrol/${e.target.checked ? "true" : "false"}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@
|
|||
/>
|
||||
<EnergyflowEntry
|
||||
v-if="batteryConfigured"
|
||||
:name="$t('main.energyflow.batteryDischarge')"
|
||||
:name="batteryDischargeLabel"
|
||||
icon="battery"
|
||||
:power="batteryDischarge"
|
||||
:powerInKw="powerInKw"
|
||||
|
|
@ -134,7 +134,7 @@
|
|||
/>
|
||||
<EnergyflowEntry
|
||||
v-if="batteryConfigured"
|
||||
:name="$t('main.energyflow.batteryCharge')"
|
||||
:name="batteryChargeLabel"
|
||||
icon="battery"
|
||||
:power="batteryCharge"
|
||||
:powerInKw="powerInKw"
|
||||
|
|
@ -197,6 +197,8 @@ export default {
|
|||
battery: { type: Array },
|
||||
batteryPower: { type: Number, default: 0 },
|
||||
batterySoc: { type: Number, default: 0 },
|
||||
batteryDischargeControl: { type: Boolean },
|
||||
batteryMode: { type: String },
|
||||
tariffGrid: { type: Number },
|
||||
tariffFeedIn: { type: Number },
|
||||
tariffCo2: { type: Number },
|
||||
|
|
@ -230,6 +232,15 @@ export default {
|
|||
batteryCharge: function () {
|
||||
return Math.abs(Math.min(0, this.batteryPower) * -1);
|
||||
},
|
||||
batteryChargeLabel: function () {
|
||||
return this.$t(`main.energyflow.battery${this.batteryHold ? "Hold" : "Charge"}`);
|
||||
},
|
||||
batteryDischargeLabel: function () {
|
||||
return this.$t(`main.energyflow.battery${this.batteryHold ? "Hold" : "Discharge"}`);
|
||||
},
|
||||
batteryHold: function () {
|
||||
return this.batteryMode === "hold";
|
||||
},
|
||||
selfConsumption: function () {
|
||||
const ownPower = this.batteryDischarge + this.pvProduction;
|
||||
const consumption = this.homePower + this.batteryCharge + this.loadpointsPower;
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@
|
|||
id="hiddenFeaturesEnabled"
|
||||
:label="`${$t('settings.hiddenFeatures.label')} 🧪`"
|
||||
>
|
||||
<div class="form-check form-switch my-1">
|
||||
<div class="form-check form-switch col-form-label">
|
||||
<input
|
||||
id="hiddenFeaturesEnabled"
|
||||
v-model="hiddenFeatures"
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ export default {
|
|||
batteryConfigured: Boolean,
|
||||
batteryPower: Number,
|
||||
batterySoc: Number,
|
||||
batteryDischargeControl: Boolean,
|
||||
batteryMode: String,
|
||||
battery: Array,
|
||||
gridCurrents: Array,
|
||||
prioritySoc: Number,
|
||||
|
|
|
|||
53
core/site.go
53
core/site.go
|
|
@ -41,10 +41,11 @@ type meterMeasurement struct {
|
|||
|
||||
// batteryMeasurement is used as slice element for publishing structured data
|
||||
type batteryMeasurement struct {
|
||||
Power float64 `json:"power"`
|
||||
Energy float64 `json:"energy,omitempty"`
|
||||
Soc float64 `json:"soc,omitempty"`
|
||||
Capacity float64 `json:"capacity,omitempty"`
|
||||
Power float64 `json:"power"`
|
||||
Energy float64 `json:"energy,omitempty"`
|
||||
Soc float64 `json:"soc,omitempty"`
|
||||
Capacity float64 `json:"capacity,omitempty"`
|
||||
Controllable bool `json:"controllable"`
|
||||
}
|
||||
|
||||
// Site is the main configuration container. A site can host multiple loadpoints.
|
||||
|
|
@ -67,7 +68,7 @@ type Site struct {
|
|||
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"` // shall discharge of home battery be adjusted
|
||||
BatteryDischargeControl bool `mapstructure:"batteryDischargeControl"` // prevent battery discharge for fast and planned charging
|
||||
|
||||
// meters
|
||||
gridMeter api.Meter // Grid usage meter
|
||||
|
|
@ -86,7 +87,7 @@ type Site struct {
|
|||
pvPower float64 // PV power
|
||||
batteryPower float64 // Battery charge power
|
||||
batterySoc float64 // Battery soc
|
||||
batteryMode api.BatteryMode // Battery discharge currently enabled
|
||||
batteryMode api.BatteryMode // Battery mode
|
||||
|
||||
publishCache map[string]any // store last published values to avoid unnecessary republishing
|
||||
}
|
||||
|
|
@ -217,6 +218,15 @@ func NewSiteFromConfig(
|
|||
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 {
|
||||
if err := site.updateBatteryMode(api.BatteryNormal); err != nil {
|
||||
site.log.ERROR.Println("battery mode:", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return site, nil
|
||||
}
|
||||
|
||||
|
|
@ -253,7 +263,7 @@ func (site *Site) restoreSettings() {
|
|||
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("site.batteryControl"); err == nil {
|
||||
site.BatteryDischargeControl = v
|
||||
}
|
||||
}
|
||||
|
|
@ -360,6 +370,10 @@ func (site *Site) publish(key string, val interface{}) {
|
|||
return
|
||||
}
|
||||
|
||||
if s, ok := val.(fmt.Stringer); ok {
|
||||
val = s.String()
|
||||
}
|
||||
|
||||
site.uiChan <- util.Param{
|
||||
Key: key,
|
||||
Val: val,
|
||||
|
|
@ -516,11 +530,14 @@ func (site *Site) updateMeters() error {
|
|||
}
|
||||
}
|
||||
|
||||
_, controllable := meter.(api.BatteryController)
|
||||
|
||||
mm[i] = batteryMeasurement{
|
||||
Power: power,
|
||||
Energy: energy,
|
||||
Soc: batSoc,
|
||||
Capacity: capacity,
|
||||
Power: power,
|
||||
Energy: energy,
|
||||
Soc: batSoc,
|
||||
Capacity: capacity,
|
||||
Controllable: controllable,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -795,8 +812,12 @@ func (site *Site) update(lp Updater) {
|
|||
site.log.ERROR.Println(err)
|
||||
}
|
||||
|
||||
if site.BatteryDischargeControl {
|
||||
site.updateBatteryMode(site.Loadpoints())
|
||||
if batMode := site.GetBatteryMode(); site.BatteryDischargeControl {
|
||||
if mode := site.determineBatteryMode(site.Loadpoints()); mode != batMode {
|
||||
if err := site.updateBatteryMode(mode); err != nil {
|
||||
site.log.ERROR.Println("battery mode:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
site.stats.Update(site)
|
||||
|
|
@ -817,13 +838,13 @@ func (site *Site) prepare() {
|
|||
site.publish("smartCostType", nil)
|
||||
site.publish("smartCostActive", false)
|
||||
if tariff := site.GetTariff(PlannerTariff); tariff != nil {
|
||||
site.publish("smartCostType", tariff.Type().String())
|
||||
site.publish("smartCostType", tariff.Type())
|
||||
}
|
||||
site.publish("currency", site.tariffs.Currency.String())
|
||||
site.publish("currency", site.tariffs.Currency)
|
||||
|
||||
site.publish("vehicles", vehicleTitles(site.GetVehicles()))
|
||||
site.publish("batteryDischargeControl", site.BatteryDischargeControl)
|
||||
site.publish("batteryMode", site.batteryMode.String())
|
||||
site.publish("batteryMode", site.batteryMode)
|
||||
}
|
||||
|
||||
// Prepare attaches communication channels to site and loadpoints
|
||||
|
|
|
|||
|
|
@ -43,4 +43,11 @@ type API interface {
|
|||
GetTariff(string) api.Tariff
|
||||
GetSmartCostLimit() float64
|
||||
SetSmartCostLimit(float64) error
|
||||
|
||||
//
|
||||
// battery control
|
||||
//
|
||||
|
||||
GetBatteryDischargeControl() bool
|
||||
SetBatteryDischargeControl(bool) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,3 +186,33 @@ func (site *Site) GetTariff(tariff string) api.Tariff {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// GetBatteryControl returns the battery control mode
|
||||
func (site *Site) GetBatteryDischargeControl() bool {
|
||||
site.Lock()
|
||||
defer site.Unlock()
|
||||
return site.BatteryDischargeControl
|
||||
}
|
||||
|
||||
// SetBatteryControl sets the battery control mode
|
||||
func (site *Site) SetBatteryDischargeControl(val bool) error {
|
||||
site.log.DEBUG.Println("set battery discharge control:", val)
|
||||
|
||||
if site.GetBatteryDischargeControl() != val {
|
||||
// reset to normal when disabling
|
||||
if mode := site.GetBatteryMode(); mode != api.BatteryNormal {
|
||||
if err := site.updateBatteryMode(api.BatteryNormal); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
site.Lock()
|
||||
defer site.Unlock()
|
||||
|
||||
site.BatteryDischargeControl = val
|
||||
settings.SetBool("site.batteryDischargeControl", val)
|
||||
site.publish("batteryDischargeControl", val)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,45 +5,43 @@ import (
|
|||
"github.com/evcc-io/evcc/core/loadpoint"
|
||||
)
|
||||
|
||||
// getBatteryMode returns the battery mode
|
||||
func (site *Site) getBatteryMode() api.BatteryMode {
|
||||
// GetBatteryMode returns the battery mode
|
||||
func (site *Site) GetBatteryMode() api.BatteryMode {
|
||||
site.Lock()
|
||||
defer site.Unlock()
|
||||
return site.batteryMode
|
||||
}
|
||||
|
||||
// setBatteryMode sets the battery mode
|
||||
func (site *Site) setBatteryMode(batMode api.BatteryMode) {
|
||||
// SetBatteryMode sets the battery mode
|
||||
func (site *Site) SetBatteryMode(batMode api.BatteryMode) {
|
||||
site.Lock()
|
||||
defer site.Unlock()
|
||||
site.batteryMode = batMode
|
||||
site.publish("batteryMode", batMode.String())
|
||||
site.publish("batteryMode", batMode)
|
||||
}
|
||||
|
||||
func (site *Site) updateBatteryMode(loadpoints []loadpoint.API) {
|
||||
// determine expected state
|
||||
batMode := api.BatteryNormal
|
||||
func (site *Site) determineBatteryMode(loadpoints []loadpoint.API) api.BatteryMode {
|
||||
for _, lp := range loadpoints {
|
||||
if lp.GetStatus() == api.StatusC && (lp.GetMode() == api.ModeNow || lp.GetPlanActive()) {
|
||||
batMode = api.BatteryHold
|
||||
break
|
||||
return api.BatteryHold
|
||||
}
|
||||
}
|
||||
|
||||
if batMode == site.getBatteryMode() {
|
||||
return
|
||||
}
|
||||
return api.BatteryNormal
|
||||
}
|
||||
|
||||
func (site *Site) updateBatteryMode(mode api.BatteryMode) error {
|
||||
// update batteries
|
||||
for _, meter := range site.batteryMeters {
|
||||
if batCtrl, ok := meter.(api.BatteryController); ok {
|
||||
if err := batCtrl.SetBatteryMode(batMode); err != nil {
|
||||
site.log.ERROR.Println("battery mode:", err)
|
||||
return
|
||||
if err := batCtrl.SetBatteryMode(mode); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// update state and publish
|
||||
site.setBatteryMode(batMode)
|
||||
site.SetBatteryMode(mode)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBatteryDischarge(t *testing.T) {
|
||||
func TestDetermineBatteryMode(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
tcs := []struct {
|
||||
|
|
@ -19,32 +19,21 @@ func TestBatteryDischarge(t *testing.T) {
|
|||
expBatMode api.BatteryMode
|
||||
mode api.ChargeMode
|
||||
}{
|
||||
{api.StatusB, false, api.BatteryNormal, api.ModeOff}, // mode off -> bat enabled
|
||||
{api.StatusB, false, api.BatteryNormal, api.ModeNow}, // mode now, not charging -> bat enabled
|
||||
{api.StatusC, false, api.BatteryHold, api.ModeNow}, // mode now, charging -> bat disabled
|
||||
{api.StatusB, false, api.BatteryNormal, api.ModeMinPV}, // mode minPV, not charging -> bat enabled
|
||||
{api.StatusC, false, api.BatteryNormal, api.ModeMinPV}, // mode minPV, charging -> bat enabled
|
||||
{api.StatusB, false, api.BatteryNormal, api.ModePV}, // mode PV, not charging -> bat enabled
|
||||
{api.StatusC, false, api.BatteryNormal, api.ModePV}, // mode PV, charging, no planner -> bat enabled
|
||||
{api.StatusC, true, api.BatteryHold, api.ModePV}, // mode PV, charging, planner active -> bat disabled
|
||||
{api.StatusB, false, api.BatteryNormal, api.ModeOff}, // mode off -> bat normal
|
||||
{api.StatusB, false, api.BatteryNormal, api.ModeNow}, // mode now, not charging -> bat normal
|
||||
{api.StatusC, false, api.BatteryHold, api.ModeNow}, // mode now, charging -> bat hold
|
||||
{api.StatusB, false, api.BatteryNormal, api.ModeMinPV}, // mode minPV, not charging -> bat normal
|
||||
{api.StatusC, false, api.BatteryNormal, api.ModeMinPV}, // mode minPV, charging -> bat normal
|
||||
{api.StatusB, false, api.BatteryNormal, api.ModePV}, // mode PV, not charging -> bat normal
|
||||
{api.StatusC, false, api.BatteryNormal, api.ModePV}, // mode PV, charging, no planner -> bat normal
|
||||
{api.StatusC, true, api.BatteryHold, api.ModePV}, // mode PV, charging, planner active -> bat hold
|
||||
}
|
||||
|
||||
log := util.NewLogger("foo")
|
||||
|
||||
for _, tc := range tcs {
|
||||
batCtrl := struct {
|
||||
*api.MockBatteryController
|
||||
*api.MockMeter
|
||||
}{
|
||||
api.NewMockBatteryController(ctrl),
|
||||
api.NewMockMeter(ctrl),
|
||||
}
|
||||
batCtrl.MockBatteryController.EXPECT().SetBatteryMode(tc.expBatMode).Times(1)
|
||||
|
||||
s := &Site{
|
||||
log: log,
|
||||
BatteryDischargeControl: true,
|
||||
batteryMeters: []api.Meter{batCtrl},
|
||||
log: log,
|
||||
}
|
||||
|
||||
lp := loadpoint.NewMockAPI(ctrl)
|
||||
|
|
@ -53,13 +42,16 @@ func TestBatteryDischarge(t *testing.T) {
|
|||
lp.EXPECT().GetPlanActive().Return(tc.planActive).AnyTimes()
|
||||
|
||||
loadpoints := []loadpoint.API{lp}
|
||||
s.updateBatteryMode(loadpoints)
|
||||
assert.Equal(t, tc.expBatMode, s.getBatteryMode(), tc)
|
||||
|
||||
mode := s.determineBatteryMode(loadpoints)
|
||||
assert.Equal(t, tc.expBatMode, mode, tc)
|
||||
}
|
||||
}
|
||||
|
||||
// test that BatteryControllers are only called if batterymode changes
|
||||
func TestBatteryModeNoUpdate(t *testing.T) {
|
||||
func TestUpdateBatteryMode(t *testing.T) {
|
||||
|
||||
expBatMode := api.BatteryHold
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
batCtrl := struct {
|
||||
|
|
@ -69,28 +61,15 @@ func TestBatteryModeNoUpdate(t *testing.T) {
|
|||
api.NewMockBatteryController(ctrl),
|
||||
api.NewMockMeter(ctrl),
|
||||
}
|
||||
batCtrl.MockBatteryController.EXPECT().SetBatteryMode(api.BatteryHold).Times(1)
|
||||
|
||||
lp := loadpoint.NewMockAPI(ctrl)
|
||||
lp.EXPECT().GetStatus().Return(api.StatusC).Times(2)
|
||||
lp.EXPECT().GetMode().Return(api.ModeNow).Times(2)
|
||||
lp.EXPECT().GetPlanActive().Times(0)
|
||||
loadpoints := []loadpoint.API{lp}
|
||||
batCtrl.MockBatteryController.EXPECT().SetBatteryMode(expBatMode).Times(1)
|
||||
|
||||
s := &Site{
|
||||
batteryMode: api.BatteryNormal,
|
||||
batteryMeters: []api.Meter{batCtrl},
|
||||
BatteryDischargeControl: true,
|
||||
log: util.NewLogger("foo"),
|
||||
log: util.NewLogger("foo"),
|
||||
batteryMeters: []api.Meter{batCtrl},
|
||||
batteryMode: api.BatteryNormal,
|
||||
}
|
||||
|
||||
s.updateBatteryMode(loadpoints) // first call should call BatteryController
|
||||
s.updateBatteryMode(loadpoints) // this one should not
|
||||
|
||||
// adjust mocks to simulate charge stop, should cause batMode udpate
|
||||
lp.EXPECT().GetStatus().Return(api.StatusB).Times(1)
|
||||
lp.EXPECT().GetMode().Return(api.ModeNow).Times(0)
|
||||
batCtrl.MockBatteryController.EXPECT().SetBatteryMode(api.BatteryNormal).Times(1)
|
||||
|
||||
s.updateBatteryMode(loadpoints) // this one should have updated again
|
||||
err := s.updateBatteryMode(expBatMode)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expBatMode, s.GetBatteryMode())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
[batterySettings]
|
||||
batteryLevel = "Ladestand"
|
||||
capacity = "{energy} von {total}"
|
||||
control = "Batteriesteuerung"
|
||||
notControllable = "Batteriesteuerung ist für diesen Speicher nicht verfügbar."
|
||||
discharge = "Verhindere Entladung im Schnell-Modus und bei geplantem Laden."
|
||||
legendBottomName = "Haus hat Priorität"
|
||||
legendBottomSubline = "nicht zum Laden verwenden"
|
||||
legendMiddleName = "Fahrzeug zuerst"
|
||||
|
|
@ -174,6 +177,7 @@ titleTargetCharge = "Abfahrt"
|
|||
battery = "Batterie"
|
||||
batteryCharge = "Batterie laden"
|
||||
batteryDischarge = "Batterie entladen"
|
||||
batteryHold = "Battery (gesperrt)"
|
||||
batteryTooltip = "{energy} von {total} ({soc})"
|
||||
gridImport = "Netzbezug"
|
||||
homePower = "Verbrauch"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
[batterySettings]
|
||||
batteryLevel = "Battery level"
|
||||
capacity = "{energy} of {total}"
|
||||
control = "Battery control"
|
||||
notControllable = "Battery control is not available for this battery."
|
||||
discharge = "Prevent discharge in fast mode and planned charging."
|
||||
legendBottomName = "home priority"
|
||||
legendBottomSubline = "not used for charging"
|
||||
legendMiddleName = "vehicle first"
|
||||
|
|
@ -171,6 +174,7 @@ titleTargetCharge = "Departure"
|
|||
battery = "Battery"
|
||||
batteryCharge = "Battery charging"
|
||||
batteryDischarge = "Battery discharging"
|
||||
batteryHold = "Battery (locked)"
|
||||
batteryTooltip = "{energy} of {total} ({soc})"
|
||||
gridImport = "Grid use"
|
||||
homePower = "Consumption"
|
||||
|
|
|
|||
|
|
@ -95,28 +95,29 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
|
|||
|
||||
// site api
|
||||
routes := map[string]route{
|
||||
"health": {[]string{"GET"}, "/health", healthHandler(site)},
|
||||
"state": {[]string{"GET"}, "/state", stateHandler(cache)},
|
||||
"config": {[]string{"GET"}, "/config/templates/{class:[a-z]+}", templatesHandler},
|
||||
"products": {[]string{"GET"}, "/config/products/{class:[a-z]+}", productsHandler},
|
||||
"device": {[]string{"GET"}, "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", deviceHandler},
|
||||
"devices": {[]string{"GET"}, "/config/devices/{class:[a-z]+}", devicesHandler},
|
||||
"newdevice": {[]string{"POST", "OPTIONS"}, "/config/devices/{class:[a-z]+}", newDeviceHandler},
|
||||
"updatedevice": {[]string{"PUT", "OPTIONS"}, "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler},
|
||||
"deletedevice": {[]string{"DELETE", "OPTIONS"}, "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler},
|
||||
"testconfig": {[]string{"POST", "OPTIONS"}, "/config/test/{class:[a-z]+}", testHandler},
|
||||
"testdevice": {[]string{"POST", "OPTIONS"}, "/config/test/{class:[a-z]+}/{id:[0-9.]+}", testHandler},
|
||||
"buffersoc": {[]string{"POST", "OPTIONS"}, "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)},
|
||||
"bufferstartsoc": {[]string{"POST", "OPTIONS"}, "/bufferstartsoc/{value:[0-9.]+}", floatHandler(site.SetBufferStartSoc, site.GetBufferStartSoc)},
|
||||
"prioritysoc": {[]string{"POST", "OPTIONS"}, "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoc, site.GetPrioritySoc)},
|
||||
"residualpower": {[]string{"POST", "OPTIONS"}, "/residualpower/{value:[-0-9.]+}", floatHandler(site.SetResidualPower, site.GetResidualPower)},
|
||||
"smartcost": {[]string{"POST", "OPTIONS"}, "/smartcostlimit/{value:[-0-9.]+}", floatHandler(site.SetSmartCostLimit, site.GetSmartCostLimit)},
|
||||
"tariff": {[]string{"GET"}, "/tariff/{tariff:[a-z]+}", tariffHandler(site)},
|
||||
"sessions": {[]string{"GET"}, "/sessions", sessionHandler},
|
||||
"session1": {[]string{"PUT", "OPTIONS"}, "/session/{id:[0-9]+}", updateSessionHandler},
|
||||
"session2": {[]string{"DELETE", "OPTIONS"}, "/session/{id:[0-9]+}", deleteSessionHandler},
|
||||
"telemetry": {[]string{"GET"}, "/settings/telemetry", boolGetHandler(telemetry.Enabled)},
|
||||
"telemetry2": {[]string{"POST", "OPTIONS"}, "/settings/telemetry/{value:[a-z]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
|
||||
"health": {[]string{"GET"}, "/health", healthHandler(site)},
|
||||
"state": {[]string{"GET"}, "/state", stateHandler(cache)},
|
||||
"config": {[]string{"GET"}, "/config/templates/{class:[a-z]+}", templatesHandler},
|
||||
"products": {[]string{"GET"}, "/config/products/{class:[a-z]+}", productsHandler},
|
||||
"device": {[]string{"GET"}, "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", deviceHandler},
|
||||
"devices": {[]string{"GET"}, "/config/devices/{class:[a-z]+}", devicesHandler},
|
||||
"newdevice": {[]string{"POST", "OPTIONS"}, "/config/devices/{class:[a-z]+}", newDeviceHandler},
|
||||
"updatedevice": {[]string{"PUT", "OPTIONS"}, "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler},
|
||||
"deletedevice": {[]string{"DELETE", "OPTIONS"}, "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler},
|
||||
"testconfig": {[]string{"POST", "OPTIONS"}, "/config/test/{class:[a-z]+}", testHandler},
|
||||
"testdevice": {[]string{"POST", "OPTIONS"}, "/config/test/{class:[a-z]+}/{id:[0-9.]+}", testHandler},
|
||||
"buffersoc": {[]string{"POST", "OPTIONS"}, "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)},
|
||||
"bufferstartsoc": {[]string{"POST", "OPTIONS"}, "/bufferstartsoc/{value:[0-9.]+}", floatHandler(site.SetBufferStartSoc, site.GetBufferStartSoc)},
|
||||
"batterydischargecontrol": {[]string{"POST", "OPTIONS"}, "/batterydischargecontrol/{value:[a-z]+}", boolHandler(site.SetBatteryDischargeControl, site.GetBatteryDischargeControl)},
|
||||
"prioritysoc": {[]string{"POST", "OPTIONS"}, "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoc, site.GetPrioritySoc)},
|
||||
"residualpower": {[]string{"POST", "OPTIONS"}, "/residualpower/{value:[-0-9.]+}", floatHandler(site.SetResidualPower, site.GetResidualPower)},
|
||||
"smartcost": {[]string{"POST", "OPTIONS"}, "/smartcostlimit/{value:[-0-9.]+}", floatHandler(site.SetSmartCostLimit, site.GetSmartCostLimit)},
|
||||
"tariff": {[]string{"GET"}, "/tariff/{tariff:[a-z]+}", tariffHandler(site)},
|
||||
"sessions": {[]string{"GET"}, "/sessions", sessionHandler},
|
||||
"session1": {[]string{"PUT", "OPTIONS"}, "/session/{id:[0-9]+}", updateSessionHandler},
|
||||
"session2": {[]string{"DELETE", "OPTIONS"}, "/session/{id:[0-9]+}", deleteSessionHandler},
|
||||
"telemetry": {[]string{"GET"}, "/settings/telemetry", boolGetHandler(telemetry.Enabled)},
|
||||
"telemetry2": {[]string{"POST", "OPTIONS"}, "/settings/telemetry/{value:[a-z]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
|
|
|
|||
|
|
@ -125,14 +125,12 @@ func boolHandler(set func(bool) error, get func() bool) http.HandlerFunc {
|
|||
vars := mux.Vars(r)
|
||||
|
||||
val, err := strconv.ParseBool(vars["value"])
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
if err == nil {
|
||||
err = set(val)
|
||||
}
|
||||
|
||||
err = set(val)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusNotAcceptable, err)
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue