Optimizer: make grid charging strategy configurable (#30782)

This commit is contained in:
andig 2026-06-16 12:27:30 +02:00 • committed by GitHub
parent ebe4a3d6a3
commit 494f6cf9bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 555 additions and 50 deletions

View file

@ -176,6 +176,7 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w
### State Management
- Never access the store from sub-components; keep them stateless and pass the values they need as props (emit events back to the parent). Only top-level views read from the store. This keeps components reusable and testable (e.g. Storybook should never mock the store).
- Use `reactive()` from Vue for simple global state
- Implement property setters for nested object updates using helper functions
- Use localStorage with reactive wrappers for persistent settings

View file

@ -0,0 +1,18 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M6 22q-.825 0-1.412-.587T4 20V10q0-.825.588-1.412T6 8h1V6q0-2.075 1.463-3.537T12 1t3.538 1.463T17 6v2h1q.825 0 1.413.588T20 10v10q0 .825-.587 1.413T18 22zm0-2h12V10H6zm6-3q.825 0 1.413-.587T14 15t-.587-1.412T12 13t-1.412.588T10 15t.588 1.413T12 17M9 8h6V6q0-1.25-.875-2.125T12 3t-2.125.875T9 6zM6 20V10z"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "Lock",
mixins: [icon],
});
</script>

View file

@ -0,0 +1,40 @@
import OptimizeHeader from "./OptimizeHeader.vue";
import { CURRENCY, OptimizationStatus } from "@/types/evcc";
import type { Meta, StoryFn } from "@storybook/vue3";
export default {
title: "Optimize/OptimizeHeader",
component: OptimizeHeader,
parameters: {
layout: "padded",
},
} as Meta<typeof OptimizeHeader>;
const base = {
updated: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
horizonHours: 47,
currency: CURRENCY.EUR,
chargingStrategies: ["charge_before_export", "attenuate_grid_peaks", "none"],
selectedStrategy: "charge_before_export",
pending: false,
};
const Template: StoryFn<typeof OptimizeHeader> = (args) => ({
components: { OptimizeHeader },
setup() {
return { args };
},
template: '<div class="container px-0"><OptimizeHeader v-bind="args" /></div>',
});
// you pay the grid (normal case): neutral color
export const Payment = Template.bind({});
Payment.args = { ...base, status: OptimizationStatus.OPTIMAL, netCost: 12.4 };
// solar surplus, you receive a credit: green, signed
export const Credit = Template.bind({});
Credit.args = { ...base, status: OptimizationStatus.OPTIMAL, netCost: -3.8 };
// solver could not produce a plan
export const Infeasible = Template.bind({});
Infeasible.args = { ...base, status: OptimizationStatus.INFEASIBLE, netCost: 0 };

View file

@ -0,0 +1,341 @@
<template>
<div class="optimize-header">
<!-- Summary fields -->
<div class="fields row g-0">
<!-- Primary goal -->
<div class="field col-12 col-md-6 col-lg-3">
<div class="field-head">
<div class="field-label small text-uppercase fw-bold evcc-gray">
Primary goal
</div>
<div class="field-caption small evcc-gray mt-1">always, fixed</div>
</div>
<div class="field-value gap-2">
<span class="large fw-bold text-lowercase evcc-default-text">Lowest cost</span>
<LockIcon class="value-icon" />
</div>
</div>
<!-- Secondary goal (only interactive setting) -->
<div class="field col-12 col-md-6 col-lg-3">
<div class="field-head">
<div class="field-label small text-uppercase fw-bold evcc-gray">
Secondary goal
</div>
<div class="field-caption small evcc-gray mt-1">only on a tie</div>
</div>
<div class="field-value gap-2">
<CustomSelect
id="optimizerSecondaryGoal"
:options="strategyOptions"
:selected="selectedStrategy"
@change="onStrategyChange"
>
<span
class="large fw-bold text-lowercase evcc-default-text secondary-goal"
>{{ secondaryGoalLabel }}</span
>
</CustomSelect>
</div>
</div>
<!-- Result -->
<div class="field col-12 col-md-6 col-lg-3">
<div class="field-head">
<div class="field-label small text-uppercase fw-bold evcc-gray">Result</div>
<div class="field-caption small evcc-gray mt-1">
<span v-if="relativeTime" class="tabular">{{ relativeTime }}, </span>
<span v-if="pending" class="updating fw-bold text-decoration-underline"
>updating…</span
>
<button
v-else
type="button"
class="btn btn-link p-0 align-baseline fw-bold text-decoration-underline update-now"
@click="$emit('optimize')"
>
update now
</button>
</div>
</div>
<div class="field-value gap-2">
<StatusIndicator :variant="statusVariant">
<span class="large fw-bold text-lowercase evcc-default-text">{{
status
}}</span>
</StatusIndicator>
<span
ref="statusInfo"
class="value-icon info-icon"
data-bs-toggle="tooltip"
role="button"
tabindex="0"
>
<shopicon-regular-info></shopicon-regular-info>
</span>
</div>
</div>
<!-- Net grid cost -->
<div class="field col-12 col-md-6 col-lg-3">
<div class="field-head">
<div class="field-label small text-uppercase fw-bold evcc-gray">
Net grid cost
</div>
<div class="field-caption small evcc-gray mt-1">
over the next {{ horizonHours }} hours
</div>
</div>
<div class="field-value gap-2">
<span
class="fs-4 fw-bold tabular"
:class="isCredit ? 'text-primary' : 'evcc-default-text'"
>
{{ netCostDisplay }}
</span>
<span
ref="costInfo"
class="value-icon info-icon"
data-bs-toggle="tooltip"
role="button"
tabindex="0"
>
<shopicon-regular-info></shopicon-regular-info>
</span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import Tooltip from "bootstrap/js/dist/tooltip";
import CustomSelect from "../Helper/CustomSelect.vue";
import StatusIndicator from "../Config/StatusIndicator.vue";
import LockIcon from "../MaterialIcon/Lock.vue";
import formatter from "@/mixins/formatter";
import minuteTicker from "@/mixins/minuteTicker";
import { CURRENCY, OptimizationStatus } from "@/types/evcc";
import "@h2d2/shopicons/es/regular/info";
// human labels for the optimizer grid charging strategies; the available values
// themselves come from backend state to avoid drift if the enum changes
const STRATEGY_LABELS: Record<string, string> = {
charge_before_export: "fill battery first",
attenuate_grid_peaks: "reduce grid peaks",
none: "no preference",
};
const STATUS_TOOLTIP =
"The optimizer result:<br><br>" +
"<strong>Optimal</strong>: the best plan was found.<br>" +
"<strong>Infeasible</strong>: the constraints cannot all be met.<br>" +
"<strong>Not solved</strong>: optimization hasn't finished yet.";
const COST_TOOLTIP =
"Forecasted net grid cost over the optimization window: energy bought minus sold, " +
"with leftover battery charge credited at the lowest price. Lower is better, " +
"positive means you pay the grid, negative means you receive a credit.";
export default defineComponent({
name: "OptimizeHeader",
components: {
CustomSelect,
StatusIndicator,
LockIcon,
},
mixins: [formatter, minuteTicker],
props: {
updated: { type: String, default: "" },
status: { type: String as PropType<OptimizationStatus>, default: undefined },
netCost: { type: Number, default: 0 },
horizonHours: { type: Number, default: 0 },
currency: { type: String as PropType<CURRENCY>, default: CURRENCY.EUR },
chargingStrategies: { type: Array as PropType<string[]>, default: () => [] },
selectedStrategy: { type: String, default: "" },
pending: { type: Boolean, default: false },
},
emits: ["optimize", "change-strategy"],
data() {
return {
tooltips: [] as Tooltip[],
};
},
computed: {
strategyOptions() {
return this.chargingStrategies.map((value) => ({
value,
name: STRATEGY_LABELS[value] || value,
}));
},
secondaryGoalLabel(): string {
return STRATEGY_LABELS[this.selectedStrategy] || this.selectedStrategy;
},
relativeTime(): string {
if (!this.updated) return "";
const elapsed = new Date(this.updated).getTime() - this.everyMinute.getTime();
if (Math.abs(elapsed) < 60 * 1000) return "just now";
return this.fmtTimeAgo(elapsed);
},
statusVariant(): "success" | "warning" | "muted" {
switch (this.status) {
case OptimizationStatus.OPTIMAL:
return "success";
case OptimizationStatus.INFEASIBLE:
case OptimizationStatus.UNBOUNDED:
return "warning";
default:
return "muted";
}
},
isCredit(): boolean {
return this.netCost < 0;
},
netCostDisplay(): string {
return this.fmtMoney(this.netCost, this.currency, true, true);
},
},
mounted() {
this.initTooltips();
},
beforeUnmount() {
this.tooltips.forEach((t) => t.dispose());
},
methods: {
onStrategyChange(e: Event) {
this.$emit("change-strategy", (e.target as HTMLSelectElement).value);
},
initTooltips() {
const items: [Element | undefined, string][] = [
[this.$refs["statusInfo"] as Element | undefined, STATUS_TOOLTIP],
[this.$refs["costInfo"] as Element | undefined, COST_TOOLTIP],
];
for (const [el, title] of items) {
if (el) {
// left-align via inner markup: tooltips render at <body>, outside scoped styles
this.tooltips.push(
new Tooltip(el, {
title: `<div class="text-start">${title}</div>`,
html: true,
})
);
}
}
},
},
});
</script>
<style scoped>
/* underline + weight come from utility classes; only the gray/hover color is custom */
.update-now {
font-size: inherit;
color: var(--evcc-gray);
}
.update-now:hover {
color: var(--bs-primary);
}
.updating {
font-size: inherit;
color: var(--evcc-gray);
cursor: progress;
}
/* small: each field a full-width row (Bootstrap col-12), label left, value right */
.field {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.75rem 0;
border-top: 1px solid var(--evcc-gray-25);
}
.field:last-child {
border-bottom: 1px solid var(--evcc-gray-25);
}
.field-head {
display: flex;
flex-direction: column;
}
.field-value {
display: flex;
align-items: center;
justify-content: flex-end;
text-align: right;
}
.value-icon {
display: inline-flex;
align-items: center;
flex-shrink: 0;
color: var(--evcc-gray);
}
.info-icon {
cursor: help;
}
.secondary-goal {
text-decoration: underline;
text-decoration-color: var(--evcc-gray);
text-underline-offset: 3px;
}
/* md and up: each field a vertical stack (label, value, caption); columns via Bootstrap grid */
@media (min-width: 768px) {
.field {
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 0;
padding: 0.75rem 1.5rem;
border: none;
}
.field:last-child {
border-bottom: none;
}
/* promote label + caption so value can sit between them via order */
.field-head {
display: contents;
}
.field-label {
order: 0;
margin-bottom: 0.5rem;
}
.field-value {
order: 1;
justify-content: flex-start;
text-align: left;
}
.field-caption {
order: 2;
}
}
/* medium (col-md-6): two columns, dividers between */
@media (min-width: 768px) and (max-width: 991.98px) {
.field:nth-child(odd) {
padding-left: 0;
}
.field:nth-child(even) {
padding-right: 0;
border-left: 1px solid var(--evcc-gray-25);
}
.field:nth-child(n + 3) {
border-top: 1px solid var(--evcc-gray-25);
}
}
/* large (col-lg-3): four equal blocks in one row, dividers between */
@media (min-width: 992px) {
.field {
padding-block: 0;
border-left: 1px solid var(--evcc-gray-25);
}
.field:first-child {
padding-left: 0;
border-left: none;
}
.field:last-child {
padding-right: 0;
}
}
</style>

View file

@ -128,6 +128,8 @@ export interface State {
ocpp?: Ocpp;
ocppforwarder?: ConfigStatus<OcppForwarderRule[], OcppForwarderSession[]>;
optimizer?: boolean;
optimizerChargingStrategy?: string;
optimizerChargingStrategies?: string[];
mcp?: boolean;
}
@ -729,6 +731,7 @@ export type ValueOf<T> = T[keyof T];
// EvOpt interfaces matching OpenAPI spec exactly
export interface EvOpt {
updated: string;
req: OptimizationInput;
res: OptimizationResult;
details: OptimizationDetails;

View file

@ -1,42 +1,25 @@
<template>
<div class="container px-4 safe-area-inset">
<TopHeader title="Optimize Debug" />
<div class="alert alert-light mb-5 d-flex justify-content-between align-items-center">
<span>
This page is for development purposes only. Gives insights into the upcoming
optimization algorithm.
</span>
<button
class="btn btn-sm ms-3 text-nowrap"
:class="optimizeCooldown ? 'btn-outline-secondary disabled' : 'btn-outline-dark'"
:disabled="optimizeCooldown"
@click="optimizeNow"
>
{{ optimizeCooldown ? "Requested" : "Optimize now" }}
</button>
</div>
<TopHeader title="Optimize Debug 🧪" />
<OptimizeHeader
class="mt-4 mb-5"
:updated="evopt?.updated"
:status="evopt?.res?.status"
:net-cost="netCost"
:horizon-hours="horizonHours"
:currency="currency"
:charging-strategies="chargingStrategies"
:selected-strategy="optimizerChargingStrategy"
:pending="pending"
@optimize="optimizeNow"
@change-strategy="changeChargingStrategy"
/>
<div class="row">
<main class="col-12">
<div v-if="evopt">
<!-- Optimizer Plan -->
<section class="mb-5">
<h3
class="fw-normal d-flex gap-3 flex-wrap d-flex align-items-baseline overflow-hidden mb-4"
>
<span class="d-block no-wrap text-truncate">Result: Charging Plan</span>
<small class="d-block no-wrap text-truncate">
{{ evopt.res.status }} ・
{{
fmtMoney(
(evopt.res.objective_value || 0) * -1,
currency,
true,
true
)
}}
net cost
</small>
</h3>
<h3 class="fw-normal mb-4">Result: Charging Plan</h3>
<ChargeChart
:evopt="evopt"
:battery-details="evopt.details.batteryDetails"
@ -137,6 +120,7 @@
<script lang="ts">
import { defineComponent } from "vue";
import Header from "../components/Top/Header.vue";
import OptimizeHeader from "../components/Optimize/OptimizeHeader.vue";
import BatteryConfigurationTable from "../components/Optimize/BatteryConfigurationTable.vue";
import SocChart from "../components/Optimize/SocChart.vue";
import ChargeChart from "../components/Optimize/ChargeChart.vue";
@ -154,6 +138,7 @@ export default defineComponent({
name: "Optimize",
components: {
TopHeader: Header,
OptimizeHeader,
BatteryConfigurationTable,
SocChart,
ChargeChart,
@ -164,7 +149,7 @@ export default defineComponent({
mixins: [formatter],
data() {
return {
optimizeCooldown: false,
pending: false,
};
},
head() {
@ -177,19 +162,19 @@ export default defineComponent({
currency() {
return store.state.currency || CURRENCY.EUR;
},
statusBadgeClass() {
if (!this.evopt?.res.status) return "bg-secondary";
switch (this.evopt.res.status) {
case "Optimal":
return "bg-success";
case "Infeasible":
return "bg-danger";
case "Unbounded":
return "bg-warning";
default:
return "bg-secondary";
}
chargingStrategies(): string[] {
return store.state.optimizerChargingStrategies || [];
},
optimizerChargingStrategy(): string {
return store.state.optimizerChargingStrategy || "";
},
netCost(): number {
return (this.evopt?.res?.objective_value || 0) * -1;
},
horizonHours(): number {
const dt = this.evopt?.req?.time_series?.dt;
if (!dt?.length) return 0;
return Math.round(dt.reduce((sum, s) => sum + s, 0) / 3600);
},
deviceColors() {
return deviceColorMap(store.state.deviceColors);
@ -216,14 +201,18 @@ export default defineComponent({
},
},
watch: {
evopt() {
this.optimizeCooldown = false;
"evopt.updated"() {
// re-enable the refresh action once a fresh optimizer run lands
this.pending = false;
},
},
methods: {
optimizeNow() {
this.pending = true;
api.post("optimize");
this.optimizeCooldown = true;
},
changeChargingStrategy(value: string) {
api.post(`optimizerchargingstrategy/${value}`);
},
dimColorBy25Percent(color: string): string {
// Convert color to 25% opacity (40 in hex = 25% of 255)

View file

@ -44,6 +44,10 @@ const (
BufferSoc = "bufferSoc"
BufferStartSoc = "bufferStartSoc"
// optimizer
OptimizerChargingStrategy = "optimizerChargingStrategy"
OptimizerChargingStrategies = "optimizerChargingStrategies"
// battery status
Battery = "battery"
BatteryMode = "batteryMode"

View file

@ -80,6 +80,9 @@ type Site struct {
batteryDischargeControl bool // prevent battery discharge for fast and planned charging
batteryGridChargeLimit *float64 // grid charging limit
// optimizer settings
optimizerChargingStrategy string // optimizer grid charging strategy
loadpoints []*Loadpoint // Loadpoints
tariffs *tariff.Tariffs // Tariffs
coordinator *coordinator.Coordinator // Vehicles
@ -347,6 +350,13 @@ func (site *Site) restoreSettings() error {
return err
}
}
if v, err := settings.String(keys.OptimizerChargingStrategy); err == nil && v != "" {
if err := site.SetOptimizerChargingStrategy(v); err != nil {
site.log.WARN.Printf("optimizer charging strategy: %v", err)
}
}
site.publish(keys.OptimizerChargingStrategy, site.GetOptimizerChargingStrategy())
site.publish(keys.OptimizerChargingStrategies, optimizerChargingStrategies)
// drop legacy accumulator-based forecast settings (now stored via metrics collector)
settings.Delete("solarAccForecast")

View file

@ -54,6 +54,11 @@ type API interface {
// SetBatteryGridChargeLimit sets the grid charge limit
SetBatteryGridChargeLimit(limit *float64) error
// GetOptimizerChargingStrategy gets the optimizer grid charging strategy
GetOptimizerChargingStrategy() string
// SetOptimizerChargingStrategy sets the optimizer grid charging strategy
SetOptimizerChargingStrategy(strategy string) error
//
// power and energy
//

View file

@ -2,6 +2,8 @@ package core
import (
"errors"
"fmt"
"slices"
"strings"
"time"
@ -383,6 +385,43 @@ func (site *Site) SetBatteryGridChargeLimit(val *float64) error {
return nil
}
// GetOptimizerChargingStrategy returns the optimizer grid charging strategy,
// falling back to the default when unset.
func (site *Site) GetOptimizerChargingStrategy() string {
site.RLock()
defer site.RUnlock()
if site.optimizerChargingStrategy == "" {
return defaultOptimizerChargingStrategy
}
return site.optimizerChargingStrategy
}
// SetOptimizerChargingStrategy validates and persists the optimizer grid
// charging strategy and re-runs the optimizer when it changes.
func (site *Site) SetOptimizerChargingStrategy(strategy string) error {
if !slices.Contains(optimizerChargingStrategies, strategy) {
return fmt.Errorf("invalid optimizer charging strategy: %s", strategy)
}
site.Lock()
changed := site.optimizerChargingStrategy != strategy
if changed {
site.optimizerChargingStrategy = strategy
}
site.Unlock()
if changed {
site.log.DEBUG.Println("set optimizer charging strategy:", strategy)
settings.SetString(keys.OptimizerChargingStrategy, strategy)
site.publish(keys.OptimizerChargingStrategy, strategy)
// re-run the optimizer so the new strategy takes effect immediately
site.triggerOptimizer()
}
return nil
}
// GetBatteryMode returns the battery mode
func (site *Site) GetBatteryMode() api.BatteryMode {
site.RLock()

View file

@ -36,11 +36,39 @@ var (
optimizerUpdated time.Time
)
// optimizerChargingStrategies are the valid grid charging strategies; the first
// entry is the default and preserves the previous hard-coded behavior.
var optimizerChargingStrategies = []string{
string(optimizer.OptimizerStrategyChargingStrategyChargeBeforeExport),
string(optimizer.OptimizerStrategyChargingStrategyAttenuateGridPeaks),
string(optimizer.OptimizerStrategyChargingStrategyNone),
}
const defaultOptimizerChargingStrategy = string(optimizer.OptimizerStrategyChargingStrategyChargeBeforeExport)
// triggerOptimizer re-runs the optimizer immediately so a changed setting takes
// effect without waiting for the next slot. It is a no-op when the optimizer is
// not active or a run is already in progress; the running update reflects the
// change on its next slot.
func (site *Site) triggerOptimizer() {
if !sponsor.IsAuthorized() || !optimizerEnabled() {
return
}
if !mu.TryLock() {
return
}
optimizerUpdated = time.Time{} // bypass the slot/debounce gate
mu.Unlock()
go site.optimizerUpdateAsync()
}
// optimizerResult wraps the optimizer publish payload to implement BytesMarshaler.
// This ensures publishComplex serializes it as a single JSON message instead of
// recursively decomposing each struct field and array element into individual MQTT
// topics (~1,500 messages per optimizer run).
type optimizerResult struct {
Updated time.Time `json:"updated"`
Req optimizer.OptimizationInput `json:"req"`
Res optimizer.OptimizationResult `json:"res"`
Details requestDetails `json:"details"`
@ -164,7 +192,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
req := optimizer.OptimizationInput{
Strategy: optimizer.OptimizerStrategy{
ChargingStrategy: optimizer.OptimizerStrategyChargingStrategyChargeBeforeExport, // AttenuateGridPeaks
ChargingStrategy: optimizer.OptimizerStrategyChargingStrategy(site.GetOptimizerChargingStrategy()),
DischargingStrategy: optimizer.OptimizerStrategyDischargingStrategyDischargeBeforeImport,
},
EtaC: eta,
@ -217,6 +245,10 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
}
for i, dev := range site.batteryMeters {
// measurements may lag the configured meters on an off-cycle trigger
if i >= len(battery) {
break
}
b := battery[i]
if b.Capacity == nil || *b.Capacity == 0 || b.Soc == nil {
@ -258,6 +290,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
}
site.publish("evopt", optimizerResult{
Updated: time.Now(),
Req: req,
Res: *resp.JSON200,
Details: details,

View file

@ -6,6 +6,7 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/util"
optimizer "github.com/evcc-io/optimizer/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -148,3 +149,17 @@ func TestBatteryForecastSocExtremes(t *testing.T) {
})
}
}
func TestOptimizerChargingStrategy(t *testing.T) {
site := &Site{log: util.NewLogger("foo")}
// default when unset
assert.Equal(t, defaultOptimizerChargingStrategy, site.GetOptimizerChargingStrategy())
// invalid value rejected, strategy unchanged
require.Error(t, site.SetOptimizerChargingStrategy("bogus"))
assert.Equal(t, defaultOptimizerChargingStrategy, site.GetOptimizerChargingStrategy())
// valid change is applied (re-trigger is gated on sponsor/enabled, not unit-tested here)
require.NoError(t, site.SetOptimizerChargingStrategy(string(optimizer.OptimizerStrategyChargingStrategyAttenuateGridPeaks)))
assert.Equal(t, "attenuate_grid_peaks", site.GetOptimizerChargingStrategy())
}

View file

@ -163,6 +163,8 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) {
"optimize": {"POST", "/optimize", getHandler(site.Optimize)},
"telemetry2": {"POST", "/settings/telemetry/{value:[01truefalse]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
"devicecolors": {"PUT", "/devicecolors", updateDeviceColor(site)},
"optimizerchargingstrategy": {"POST", "/optimizerchargingstrategy/{value:[a-z_]+}", stringHandler(site.SetOptimizerChargingStrategy, site.GetOptimizerChargingStrategy)},
}
for _, r := range routes {

View file

@ -143,6 +143,11 @@ func boolHandler(set func(bool) error, get func() bool) http.HandlerFunc {
return handler(strconv.ParseBool, set, get)
}
// stringHandler updates string-param api
func stringHandler(set func(string) error, get func() string) http.HandlerFunc {
return handler(func(s string) (string, error) { return s, nil }, set, get)
}
// durationHandler updates duration-param api
func durationHandler(set func(time.Duration) error, get func() time.Duration) http.HandlerFunc {
return handler(util.ParseDuration, set, get)