Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
76c333c
feat(battery): add grid discharge (feed-in arbitrage) mode
webalexeu Aug 7, 2026
7ae6080
battery: warn and prioritize charge on grid charge/discharge conflict
webalexeu Aug 7, 2026
7071065
fix(battery): re-validate min-soc reserve every cycle during grid dis…
webalexeu Aug 7, 2026
4a59819
plugin: switch returns ErrNotAvailable for unmatched value
andig Aug 9, 2026
c2b7ec7
battery: address review findings on grid discharge
andig Aug 9, 2026
a4f0de3
site: log missing feed-in rate, reuse BatteryDischarge for the sugges…
andig Aug 9, 2026
dad9ab8
api: document battery discharge mode and grid discharge limit endpoints
andig Aug 9, 2026
a481e04
battery: extract fromTo mode helper
andig Aug 9, 2026
cde7fdd
api: regenerate mcp tool docs for grid discharge limit
andig Aug 9, 2026
729c390
battery: hold grid discharge for fast-charging EV regardless of disch…
webalexeu Aug 10, 2026
60687f4
Merge remote-tracking branch 'upstream/master' into feat/battery-grid…
webalexeu Aug 10, 2026
d3b02ca
battery: gate the grid discharge limit behind the experimental setting
andig Aug 16, 2026
840a964
battery: cover that no grid discharge limit control shows while grid …
andig Aug 16, 2026
c2048bf
battery: add the grid discharge limit setting behind the experimental…
andig Aug 16, 2026
375b331
battery: require a dynamic feed-in tariff for the grid discharge limit
andig Aug 16, 2026
440deae
Battery: keep the grid discharge limit tied to its opt-in
andig Aug 16, 2026
8b6ff1a
Merge remote-tracking branch 'upstream/master' into feat/battery-grid…
webalexeu Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion api/batterymode.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package api

// BatteryMode is the home battery operation mode. Valid values are normal, hold, charge and holdcharge
// BatteryMode is the home battery operation mode. Valid values are normal, hold, charge, holdcharge and discharge
type BatteryMode int

//go:generate go tool enumer -type BatteryMode -trimprefix Battery -transform=lower
Expand All @@ -10,4 +10,5 @@ const (
BatteryHold
BatteryCharge
BatteryHoldCharge
BatteryDischarge // forced discharge to grid (feed-in arbitrage)
)
12 changes: 8 additions & 4 deletions api/batterymode_enumer.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 38 additions & 0 deletions assets/js/components/Battery/BatteryExperimental.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@
>
<SmartCostLimit v-bind="smartCostLimitProps" />
</Card>

<Card
v-if="gridDischargeVisible"
class="box-pull-out mt-4"
:title="$t('batterySettings.gridDischargeTab')"
data-testid="battery-grid-discharge-limit"
>
<SmartFeedInPriority v-bind="smartFeedInPriorityProps" />
</Card>
</div>
<p v-else class="my-4 text-muted">{{ $t("batterySettings.noBattery") }}</p>
</template>
Expand All @@ -44,6 +53,7 @@ import api from "@/api";
import { SMART_COST_TYPE, CURRENCY, type BatteryMeter } from "@/types/evcc";
import Card from "../Helper/Card.vue";
import SmartCostLimit from "../Tariff/SmartCostLimit.vue";
import SmartFeedInPriority from "../Tariff/SmartFeedInPriority.vue";
import BatteryStatusCards from "./BatteryStatusCards.vue";
import BatteryConfigCard from "./BatteryConfigCard.vue";
import BatteryHistoryCard from "./BatteryHistoryCard.vue";
Expand All @@ -57,6 +67,7 @@ export default defineComponent({
components: {
Card,
SmartCostLimit,
SmartFeedInPriority,
BatteryStatusCards,
BatteryConfigCard,
BatteryHistoryCard,
Expand Down Expand Up @@ -130,6 +141,33 @@ export default defineComponent({
possible: this.gridChargePossible,
};
},
gridDischargeLimit(): number | null {
return this.state.batteryGridDischargeLimit ?? null;
},
// needs a dynamic feed-in tariff, the grid price is irrelevant here
gridDischargePossible(): boolean {
return (
this.devices.some(({ controllable }) => controllable) &&
!!this.state.smartFeedInPriorityAvailable
);
},
// the limit is inert unless the experimental grid discharge setting is on. a
// limit that is already set stays reachable so it can be removed
gridDischargeVisible(): boolean {
return (
!!this.state.batteryGridDischarge &&
(this.gridDischargePossible || this.gridDischargeLimit !== null)
);
},
Comment on lines +156 to +161

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hides the card whenever batteryGridDischarge is off, even when a limit is already set — contradicting the comment above it. Compare gridChargeVisible just above (no such gate, correctly keeps a stale limit reachable/removable).

Suggested change
gridDischargeVisible(): boolean {
return (
!!this.state.batteryGridDischarge &&
(this.gridDischargePossible || this.gridDischargeLimit !== null)
);
},
gridDischargeVisible(): boolean {
return (
this.gridDischargeLimit !== null ||
(!!this.state.batteryGridDischarge && this.gridDischargePossible)
);
},

smartFeedInPriorityProps() {
return {
currentLimit: this.gridDischargeLimit,
lastLimit: settings.lastBatteryGridDischargeLimit,
currency: this.state.currency || CURRENCY.EUR,
tariff: store.uiForecast.value.feedin,
possible: this.gridDischargePossible,
};
},
},
watch: {
// fetchHistory reloads only when the needed range is not already covered
Expand Down
1 change: 1 addition & 0 deletions assets/js/components/Loadpoints/SettingsModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
:last-limit="loadpoint?.lastSmartFeedInPriorityLimit"
:currency="currency"
:loadpoint-id="id"
is-loadpoint
:multiple-loadpoints="multipleLoadpoints"
:possible="smartFeedInPriorityAvailable"
:tariff="forecast?.feedin"
Expand Down
26 changes: 18 additions & 8 deletions assets/js/components/Tariff/SmartFeedInPriority.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
:current-limit="currentLimit"
:last-limit="lastLimit"
:currency="currency"
:apply-all="multipleLoadpoints"
:apply-all="isLoadpoint && multipleLoadpoints"
:possible="possible"
:tariff="tariff"
:form-id="formId"
Expand All @@ -23,6 +23,7 @@
import { defineComponent, type PropType } from "vue";
import SmartTariffBase from "./SmartTariffBase.vue";
import api from "@/api";
import settings from "@/settings";
import { type CURRENCY } from "@/types/evcc";
import { setLoadpointLastSmartFeedInPriorityLimit } from "@/uiLoadpoints";

Expand All @@ -37,19 +38,20 @@ export default defineComponent({
lastLimit: Number,
currency: String as PropType<CURRENCY>,
loadpointId: String,
isLoadpoint: Boolean,
multipleLoadpoints: Boolean,
possible: Boolean,
tariff: Array,
},
computed: {
formId(): string {
return `smartFeedInPriority-${this.loadpointId}`;
return `smartFeedInPriority-${this.loadpointId || "battery"}`;
},
labels() {
const t = (key: string) => this.$t(`smartFeedInPriority.${key}`);
return {
title: t("title"),
description: t("description"),
title: this.isLoadpoint ? t("title") : "",
description: this.isLoadpoint ? t("description") : t("batteryDescription"),
limitLabel: t("priceLimit"),
activeHoursLabel: t("activeHoursLabel"),
currentPriceLabel: t("priceLabel"),
Expand All @@ -71,19 +73,27 @@ export default defineComponent({

if (!active) return;

const url = `loadpoints/${this.loadpointId}/smartfeedinprioritylimit`;
const url = this.isLoadpoint
? `loadpoints/${this.loadpointId}/smartfeedinprioritylimit`
: "batterygriddischargelimit";

await api.post(`${url}/${encodeURIComponent(limit)}`);
},
saveLastLimit(limit: number) {
if (this.loadpointId) {
setLoadpointLastSmartFeedInPriorityLimit(this.loadpointId, limit);
if (this.isLoadpoint) {
setLoadpointLastSmartFeedInPriorityLimit(this.loadpointId!, limit);
} else {
settings.lastBatteryGridDischargeLimit = limit;
}
},
async deleteLimit() {
// save last selected value to be suggest again when reactivating limit
this.saveLastLimit(this.currentLimit || 0);

const url = `loadpoints/${this.loadpointId}/smartfeedinprioritylimit`;
const url = this.isLoadpoint
? `loadpoints/${this.loadpointId}/smartfeedinprioritylimit`
: "batterygriddischargelimit";

await api.delete(url);
},
async applyToAll(selectedLimit: number | null) {
Expand Down
4 changes: 4 additions & 0 deletions assets/js/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const BATTERY_UNIT = "battery_unit";
const SETTINGS_PRICE_ZOOM = "settings_price_zoom";
const SETTINGS_HIDE_FEEDIN = "settings_hide_feedin";
const LAST_BATTERY_SMART_COST_LIMIT = "last_battery_smart_cost_limit";
const LAST_BATTERY_GRID_DISCHARGE_LIMIT = "last_battery_grid_discharge_limit";
const LAST_TARGET_TIME = "last_target_time";
const LAST_SOC_GOAL = "last_soc_goal";
const LAST_ENERGY_GOAL = "last_energy_goal";
Expand Down Expand Up @@ -129,6 +130,7 @@ export interface Settings {
hideFeedin: boolean;
loadpoints: Record<string, LoadpointSettings>;
lastBatterySmartCostLimit: number | undefined;
lastBatteryGridDischargeLimit: number | undefined;
lastTargetTime: string | null;
lastSocGoal: number | undefined;
lastEnergyGoal: number | undefined;
Expand Down Expand Up @@ -158,6 +160,7 @@ const settings: Settings = reactive({
hideFeedin: readBool(SETTINGS_HIDE_FEEDIN),
loadpoints: readJSON(LOADPOINTS),
lastBatterySmartCostLimit: readNumber(LAST_BATTERY_SMART_COST_LIMIT),
lastBatteryGridDischargeLimit: readNumber(LAST_BATTERY_GRID_DISCHARGE_LIMIT),
lastTargetTime: read(LAST_TARGET_TIME),
lastSocGoal: readNumber(LAST_SOC_GOAL),
lastEnergyGoal: readNumber(LAST_ENERGY_GOAL),
Expand Down Expand Up @@ -186,6 +189,7 @@ watch(() => settings.priceZoom, saveBool(SETTINGS_PRICE_ZOOM));
watch(() => settings.hideFeedin, saveBool(SETTINGS_HIDE_FEEDIN));
watch(() => settings.loadpoints, saveJSON(LOADPOINTS), { deep: true });
watch(() => settings.lastBatterySmartCostLimit, saveNumber(LAST_BATTERY_SMART_COST_LIMIT));
watch(() => settings.lastBatteryGridDischargeLimit, saveNumber(LAST_BATTERY_GRID_DISCHARGE_LIMIT));
watch(() => settings.lastTargetTime, save(LAST_TARGET_TIME));
watch(() => settings.lastSocGoal, saveNumber(LAST_SOC_GOAL));
watch(() => settings.lastEnergyGoal, saveNumber(LAST_ENERGY_GOAL));
Expand Down
5 changes: 5 additions & 0 deletions assets/js/types/evcc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,10 @@ export interface State {
batteryGridChargeLimit?: number | null;
/** Home battery is currently charged from grid. */
batteryGridChargeActive?: boolean;
/** Feed-in price limit for discharging the home battery to the grid (experimental). */
batteryGridDischargeLimit?: number | null;
/** Home battery is currently discharged to the grid. */
batteryGridDischargeActive?: boolean;
/** A dynamic grid price or CO₂ forecast is configured. */
smartCostAvailable?: boolean;
/** Type of the smart charging limit, price based or emission based. */
Expand Down Expand Up @@ -830,6 +834,7 @@ export enum BATTERY_MODE {
HOLD = "hold",
CHARGE = "charge",
HOLDCHARGE = "holdcharge",
DISCHARGE = "discharge",
}

export enum PHASES {
Expand Down
2 changes: 1 addition & 1 deletion cmd/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const (
flagCustomPhoneDescription = "Support phone number shown in the UI"

flagBatteryMode = "battery-mode"
flagBatteryModeDescription = "Set battery mode (normal, hold, charge, holdcharge)"
flagBatteryModeDescription = "Set battery mode (normal, hold, charge, holdcharge, discharge)"
flagBatteryModeWait = "battery-mode-wait"
flagBatteryModeWaitDescription = "Wait given duration during which potential watchdogs are active"

Expand Down
14 changes: 8 additions & 6 deletions core/keys/site.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,14 @@ const (
ConsumerMeters = "consumerMeters"

// battery settings
BatteryDischargeControl = "batteryDischargeControl"
BatteryGridChargeLimit = "batteryGridChargeLimit"
BatteryGridChargeActive = "batteryGridChargeActive"
BatteryGridDischarge = "batteryGridDischarge"
BufferSoc = "bufferSoc"
BufferStartSoc = "bufferStartSoc"
BatteryDischargeControl = "batteryDischargeControl"
BatteryGridChargeLimit = "batteryGridChargeLimit"
BatteryGridChargeActive = "batteryGridChargeActive"
BatteryGridDischargeLimit = "batteryGridDischargeLimit"
BatteryGridDischargeActive = "batteryGridDischargeActive"
BatteryGridDischarge = "batteryGridDischarge"
BufferSoc = "bufferSoc"
BufferStartSoc = "bufferStartSoc"

// grid settings
GridExportLimit = "gridExportLimit"
Expand Down
29 changes: 22 additions & 7 deletions core/site.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,13 @@ type Site struct {
curtailPercent *int

// 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
batteryDischargeControl bool // prevent battery discharge for fast and planned charging
batteryGridChargeLimit *float64 // grid charging limit
batteryGridDischarge bool // allow battery discharge to grid (experimental)
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
batteryDischargeControl bool // prevent battery discharge for fast and planned charging
batteryGridChargeLimit *float64 // grid charging limit
batteryGridDischargeLimit *float64 // grid discharging (feed-in) limit
batteryGridDischarge bool // allow battery discharge to grid (experimental)

// grid settings
gridExportLimit float64 // static grid export power limit in W, 0 = disabled
Expand Down Expand Up @@ -406,6 +407,11 @@ func (site *Site) restoreSettings() error {
return err
}
}
if v, err := settings.Float(keys.BatteryGridDischargeLimit); err == nil {
if err := site.SetBatteryGridDischargeLimit(&v); err != nil && !errors.Is(err, ErrBatteryControlNotAvailable) {
return err
}
}
if v, err := settings.Bool(keys.SolarAdjusted); err == nil {
site.SetSolarAdjusted(v)
}
Expand Down Expand Up @@ -1222,7 +1228,16 @@ func (site *Site) update(lp updater) {
// update battery after reading meters to ensure that (modbus) connection is open
batteryGridChargeActive := site.batteryGridChargeActive(rate)
site.publish(keys.BatteryGridChargeActive, batteryGridChargeActive)
site.updateBatteryMode(batteryGridChargeActive, rate)

// grid discharge (feed-in arbitrage) uses the feed-in rate, not the grid rate
feedinRate, err := feedin.At(time.Now())
if feedin != nil && err != nil {
site.log.WARN.Printf("feed-in: no matching rate for: %s", time.Now().Format(time.RFC3339))
}
batteryGridDischargeActive := site.batteryGridDischargeActive(feedinRate)
site.publish(keys.BatteryGridDischargeActive, batteryGridDischargeActive)

site.updateBatteryMode(batteryGridChargeActive, batteryGridDischargeActive, rate)
Comment thread
andig marked this conversation as resolved.
Outdated

// re-evaluate against the updated loadpoint state
site.publishSuggestions()
Expand Down
4 changes: 4 additions & 0 deletions core/site/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ type API interface {
GetBatteryGridChargeLimit() *float64
// SetBatteryGridChargeLimit sets the grid charge limit
SetBatteryGridChargeLimit(limit *float64) error
// GetBatteryGridDischargeLimit get the grid discharge (feed-in) limit
GetBatteryGridDischargeLimit() *float64
// SetBatteryGridDischargeLimit sets the grid discharge (feed-in) limit
SetBatteryGridDischargeLimit(limit *float64) error

// GetOptimizerChargingStrategy gets the optimizer grid charging strategy
GetOptimizerChargingStrategy() string
Expand Down
31 changes: 31 additions & 0 deletions core/site_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,37 @@ func (site *Site) SetBatteryGridChargeLimit(val *float64) error {
return nil
}

func (site *Site) GetBatteryGridDischargeLimit() *float64 {
site.RLock()
defer site.RUnlock()
return site.batteryGridDischargeLimit
}

func (site *Site) SetBatteryGridDischargeLimit(val *float64) error {
site.log.DEBUG.Println("set grid discharge limit:", printPtr("%.1f", val))

if !site.hasBatteryControl() {
return ErrBatteryControlNotAvailable
}

site.Lock()
defer site.Unlock()

if !ptrValueEqual(site.batteryGridDischargeLimit, val) {
site.batteryGridDischargeLimit = val

if val == nil {
settings.SetString(keys.BatteryGridDischargeLimit, "")
site.publish(keys.BatteryGridDischargeLimit, nil)
} else {
settings.SetFloat(keys.BatteryGridDischargeLimit, *val)
site.publish(keys.BatteryGridDischargeLimit, *val)
}
}

return nil
}

// GetOptimizerChargingStrategy returns the optimizer grid charging strategy,
// falling back to the default when unset.
func (site *Site) GetOptimizerChargingStrategy() string {
Expand Down
Loading
Loading