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:
andig 2023-11-22 13:55:11 +01:00 • committed by GitHub
parent c55aec281f
commit ed9e5f398a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 389 additions and 289 deletions

View file

@ -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>

View file

@ -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;

View file

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

View file

@ -69,6 +69,8 @@ export default {
batteryConfigured: Boolean,
batteryPower: Number,
batterySoc: Number,
batteryDischargeControl: Boolean,
batteryMode: String,
battery: Array,
gridCurrents: Array,
prioritySoc: Number,

View file

@ -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

View file

@ -43,4 +43,11 @@ type API interface {
GetTariff(string) api.Tariff
GetSmartCostLimit() float64
SetSmartCostLimit(float64) error
//
// battery control
//
GetBatteryDischargeControl() bool
SetBatteryDischargeControl(bool) error
}

View file

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

View file

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

View file

@ -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())
}

View file

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

View file

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

View file

@ -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 {

View file

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