Optimizer: enable by default (#28213)

This commit is contained in:
andig 2026-04-02 19:50:29 +02:00 • committed by GitHub
parent f24b670312
commit b93061bc86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 151 additions and 6 deletions

View file

@ -0,0 +1,68 @@
<template>
<GenericModal
id="optimizerModal"
:title="$t('config.optimizer.title')"
config-modal-name="optimizer"
data-testid="optimizer-modal"
>
<p>
{{ $t("config.optimizer.description") }}
<a :href="docsLink" target="_blank">{{ $t("config.general.docsLink") }}</a>
</p>
<ErrorMessage :error="error" />
<div class="form-check form-switch my-3">
<input
id="optimizerEnabled"
:checked="enabled"
class="form-check-input"
type="checkbox"
role="switch"
@change="change"
/>
<div class="form-check-label">
<label for="optimizerEnabled">
{{ $t("config.optimizer.enable") }}
</label>
</div>
</div>
</GenericModal>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import GenericModal from "../Helper/GenericModal.vue";
import ErrorMessage from "../Helper/ErrorMessage.vue";
import api from "@/api";
import store from "@/store";
import { docsPrefix } from "@/i18n";
import type { AxiosError } from "axios";
export default defineComponent({
name: "OptimizerModal",
components: { GenericModal, ErrorMessage },
data() {
return {
error: null as string | null,
};
},
computed: {
enabled(): boolean {
return !!store.state?.optimizer;
},
docsLink(): string {
return `${docsPrefix()}/docs/features/optimizer`;
},
},
methods: {
async change(e: Event) {
try {
this.error = null;
await api.post(`config/optimizer/${(e.target as HTMLInputElement).checked}`);
} catch (err) {
const e = err as AxiosError<{ error: string }>;
this.error = e.response?.data?.error || e.message;
}
},
},
});
</script>

View file

@ -0,0 +1,18 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M3.75 19.75q-.325-.325-.325-.75t.325-.75L9.8 12.2q.15-.15.325-.213t.375-.062q.2 0 .375.062t.325.213l3.3 3.3l6.4-7.2q.275-.325.713-.325t.737.3q.275.275.287.663t-.262.687L15.2 17.7q-.15.175-.337.263t-.388.087q-.2 0-.387-.075t-.338-.225L10.5 14.5l-5.25 5.25q-.325.325-.75.325t-.75-.325ZM4 13.3q-.125 0-.25-.063t-.2-.212l-.5-1.075l-1.075-.5q-.15-.075-.213-.2T1.7 11q0-.125.063-.25t.212-.2l1.075-.5l.5-1.075q.075-.15.2-.212T4 8.7q.125 0 .25.063t.2.212l.5 1.075l1.075.5q.275.125.275.45t-.275.45l-1.075.5l-.5 1.075q-.075.15-.2.212T4 13.3Zm11-2q-.125 0-.25-.063t-.2-.212l-.5-1.075l-1.075-.5q-.15-.075-.212-.2T12.7 9q0-.125.063-.25t.212-.2l1.075-.5l.5-1.075q.075-.15.2-.212T15 6.7q.125 0 .25.063t.2.212l.5 1.075l1.075.5q.15.075.213.2T17.3 9q0 .125-.063.25t-.212.2l-1.075.5l-.5 1.075q-.075.15-.2.213T15 11.3Zm-6.5-3q-.125 0-.25-.075T8.05 8L7.4 6.6L6 5.95q-.15-.075-.225-.2T5.7 5.5q0-.125.075-.25T6 5.05l1.4-.65l.65-1.4q.075-.15.2-.225T8.5 2.7q.125 0 .25.075t.2.225l.65 1.4l1.4.65q.15.075.225.2t.075.25q0 .125-.075.25t-.225.2l-1.4.65L8.95 8q-.075.15-.2.225T8.5 8.3Z"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "Optimizer",
mixins: [icon],
});
</script>

View file

@ -112,6 +112,7 @@ export interface State {
config?: string;
database?: string;
ocpp?: Ocpp;
optimizer?: boolean;
}
export interface ConfigStatus<C, S> {

View file

@ -321,6 +321,19 @@
<DeviceTags :tags="hemsTags" />
</template>
</DeviceCard>
<DeviceCard
v-if="experimental"
:title="`${$t('config.optimizer.title')} 🧪`"
editable
:unconfigured="isUnconfigured(optimizerTags)"
data-testid="optimizer"
@edit="openModal('optimizer')"
>
<template #icon><OptimizerIcon /></template>
<template #tags>
<DeviceTags :tags="optimizerTags" />
</template>
</DeviceCard>
</div>
<h2 class="my-4 mt-5">{{ $t("config.section.services") }}</h2>
@ -402,6 +415,7 @@
<TariffsLegacyModal @changed="loadDirty" />
<TariffModal :currency="currency" @changed="tariffChanged" />
<TelemetryModal :sponsor="sponsor" :telemetry="telemetry" />
<OptimizerModal />
<ExperimentalModal :experimental="experimental" />
<TitleModal @changed="loadDirty" />
<ModbusProxyModal :is-sponsor="isSponsor" @changed="loadDirty" />
@ -461,6 +475,8 @@ import MqttIcon from "../components/MaterialIcon/Mqtt.vue";
import MqttModal from "../components/Config/MqttModal.vue";
import NetworkModal from "../components/Config/NetworkModal.vue";
import NotificationIcon from "../components/MaterialIcon/Notification.vue";
import OptimizerIcon from "../components/MaterialIcon/Optimizer.vue";
import OptimizerModal from "../components/Config/OptimizerModal.vue";
import restart, { performRestart } from "../restart";
import SponsorModal from "../components/Config/SponsorModal.vue";
import store from "../store";
@ -541,6 +557,8 @@ export default defineComponent({
MqttModal,
NetworkModal,
NotificationIcon,
OptimizerIcon,
OptimizerModal,
SponsorModal,
TariffsLegacyModal,
TariffCard,
@ -763,6 +781,10 @@ export default defineComponent({
eebus() {
return store.state?.eebus;
},
optimizerTags(): DeviceTags {
if (!store.state?.optimizer) return { configured: { value: false } };
return { configured: { value: true } };
},
modbusproxyTags(): DeviceTags {
const config = store.state?.modbusproxy || [];
if (config.length > 0) {

View file

@ -392,6 +392,7 @@ func runRoot(cmd *cobra.Command, args []string) {
valueChan <- util.Param{Key: keys.System, Val: util.System()}
valueChan <- util.Param{Key: keys.Timezone, Val: time.Now().Format("MST -07:00")}
valueChan <- util.Param{Key: keys.Experimental, Val: isExperimental()}
valueChan <- util.Param{Key: keys.Optimizer, Val: isOptimizer()}
// run shutdown functions on stop
var once sync.Once

View file

@ -1315,3 +1315,9 @@ func isExperimental() bool {
b, _ := settings.Bool(keys.Experimental)
return b
}
// isOptimizer returns if optimizer is enabled
func isOptimizer() bool {
b, _ := settings.Bool(keys.Optimizer)
return b
}

View file

@ -28,6 +28,7 @@ const (
SetupRequired = "setupRequired" // initial setup is required (lp = 0), fresh installation
Plant = "plant"
Telemetry = "telemetry"
Optimizer = "optimizer"
DemoMode = "demoMode"
AuthDisabled = "authDisabled"
AuthProviders = "authProviders"

View file

@ -767,13 +767,19 @@ func (site *Site) updateMeters() error {
return err
}
if sponsor.IsAuthorized() {
if sponsor.IsAuthorized() && optimizerEnabled() {
go site.optimizerUpdateAsync()
}
return nil
}
func optimizerEnabled() bool {
exp, _ := settings.Bool(keys.Experimental)
opt, _ := settings.Bool(keys.Optimizer)
return exp && opt
}
func (site *Site) updateHomeConsumption(homePower float64) {
site.householdEnergy.AddPower(homePower)

View file

@ -54,6 +54,8 @@ func (r optimizerResult) MarshalBytes() ([]byte, error) {
type batteryType string
const (
OPTIMIZER_URI = "https://optimizer.evcc.io"
batteryTypeLoadpoint batteryType = "loadpoint"
batteryTypeVehicle batteryType = "vehicle"
batteryTypeBattery batteryType = "battery"
@ -127,11 +129,6 @@ func (site *Site) optimizerUpdateAsync() {
}
func (site *Site) optimizerUpdate(battery []types.Measurement) error {
uri := os.Getenv("OPTIMIZER_URI")
if uri == "" {
return nil
}
solarTariff := site.GetTariff(api.TariffUsageSolar)
solar := currentRates(solarTariff)
@ -237,6 +234,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
httpClient := request.NewClient(site.log)
httpClient.Timeout = 30 * time.Second
uri := lo.CoalesceOrEmpty(os.Getenv("OPTIMIZER_URI"), OPTIMIZER_URI)
apiClient, err := optimizer.NewClientWithResponses(uri, optimizer.WithHTTPClient(httpClient))
if err != nil {
return err

View file

@ -599,6 +599,11 @@
"url": "Server-URL",
"urlHelp": "Kopiere diese URL in die Konfiguration deiner Wallbox. Details findest du im Handbuch des Herstellers. Die Wallbox sollte automatisch ihre eindeutige Kennung (Station-ID) an die URL anhängen. In seltenen Fällen musst du die Kennung manuell angeben. Beispiel: `{url}`"
},
"optimizer": {
"description": "Analysiert Solarprognose, Strompreise und deinen typischen Verbrauch, um Batterie- und Ladestrategie zu optimieren. Daten werden zur Berechnung an den evcc Optimierungsdienst übertragen.",
"enable": "Optimizer aktivieren",
"title": "Optimizer"
},
"options": {
"boolean": {
"no": "nein",

View file

@ -599,6 +599,11 @@
"url": "Server URL",
"urlHelp": "Copy this URL into your charger's configuration. Check the manufacturer's manual for details. The charger is expected to automatically append its unique identifier (station ID) to the url. In rare cases, you may need to manually specify the identifier. Example: `{url}`"
},
"optimizer": {
"description": "Analyzes solar forecast, electricity prices, and your consumption patterns to optimize battery and charging strategy. Data is sent to the evcc optimization service for processing.",
"enable": "Enable Optimizer",
"title": "Optimizer"
},
"options": {
"boolean": {
"no": "no",

View file

@ -295,6 +295,7 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti
"updatesponsortoken": {"POST", "/sponsortoken", updateSponsortokenHandler(pub)},
"deletesponsortoken": {"DELETE", "/sponsortoken", deleteSponsorTokenHandler(pub)},
"experimental": {"POST", "/experimental/{value:[01truefalse]+}", boolHandler(setExperimental(pub), getExperimental)},
"optimizer": {"POST", "/optimizer/{value:[01truefalse]+}", boolHandler(setOptimizer(pub), getOptimizer)},
}
// yaml handlers

View file

@ -10,6 +10,19 @@ import (
"github.com/evcc-io/evcc/util/sponsor"
)
func setOptimizer(pub publisher) func(bool) error {
return func(b bool) error {
settings.SetBool(keys.Optimizer, b)
pub(keys.Optimizer, b)
return nil
}
}
func getOptimizer() bool {
b, _ := settings.Bool(keys.Optimizer)
return b
}
func setExperimental(pub publisher) func(bool) error {
return func(b bool) error {
settings.SetBool(keys.Experimental, b)