Skip to content

Commit 1abd7a5

Browse files
committed
tariff: support cron expressions for refresh scheduling
1 parent 531a0bc commit 1abd7a5

30 files changed

Lines changed: 527 additions & 112 deletions

assets/js/components/Config/PropertyField.vue

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@
9494
:list="datalistId"
9595
:type="inputType"
9696
:step="step"
97-
:placeholder="placeholder"
97+
:placeholder="effectivePlaceholder"
9898
:required="required"
9999
:pattern="patternRegex"
100100
:title="patternTitle"
@@ -191,13 +191,40 @@ export default {
191191
},
192192
emits: ["update:modelValue"],
193193
data: () => {
194-
return { selectMode: false, unitOverride: null };
194+
return { selectMode: false, unitOverride: null, cronMode: false };
195+
},
196+
created() {
197+
// start in cron mode if the existing value isn't a parseable duration
198+
if (
199+
this.cronCapable &&
200+
typeof this.modelValue === "string" &&
201+
this.modelValue !== "" &&
202+
parseGoDuration(this.modelValue) === null
203+
) {
204+
this.cronMode = true;
205+
}
195206
},
196207
computed: {
208+
cronCapable() {
209+
return this.property === "interval" && this.type === "Duration";
210+
},
211+
cronActive() {
212+
return this.cronCapable && this.cronMode;
213+
},
214+
effectivePlaceholder() {
215+
if (this.cronActive) return "15 0 * * *";
216+
return this.placeholder;
217+
},
218+
// loose guardrail matching cron forms while rejecting a plain duration like "1h"; backend is authoritative
219+
cronPattern() {
220+
return "@(annually|yearly|monthly|weekly|daily|midnight|hourly|reboot)|@every\\s+\\S+|(\\S+\\s+){4,5}\\S+";
221+
},
197222
patternRegex() {
223+
if (this.cronActive) return this.cronPattern;
198224
return this.pattern.Regex || null;
199225
},
200226
patternTitle() {
227+
if (this.cronActive) return this.$t("config.form.cronInvalid");
201228
const examples = this.pattern.Examples || [];
202229
if (!examples.length) return null;
203230
return examples.join(", ");
@@ -229,6 +256,9 @@ export default {
229256
if (this.masked) {
230257
return "password";
231258
}
259+
if (this.cronActive) {
260+
return "text";
261+
}
232262
if (["Int", "Float", "Duration", "PricePerKWh"].includes(this.type)) {
233263
return "number";
234264
}
@@ -254,6 +284,9 @@ export default {
254284
return result;
255285
},
256286
endAlign() {
287+
if (this.cronActive) {
288+
return false;
289+
}
257290
return ["Int", "Float", "Duration", "PricePerKWh"].includes(this.type);
258291
},
259292
step() {
@@ -263,6 +296,9 @@ export default {
263296
return null;
264297
},
265298
unitValue() {
299+
if (this.cronActive) {
300+
return this.$t("config.form.cron");
301+
}
266302
if (this.type === "Duration") {
267303
return this.fmtDurationUnit(this.value, this.durationUnit);
268304
}
@@ -324,16 +360,27 @@ export default {
324360
return displayFactors[this.durationUnit] ?? 1;
325361
},
326362
durationUnit() {
363+
if (this.cronActive) {
364+
return "cron";
365+
}
327366
return this.unitOverride ?? goDurationUnit(this.modelValue) ?? this.unit ?? "second";
328367
},
329368
unitSelectable() {
330369
return this.type === "Duration" && !this.legacyDuration && !this.disabled;
331370
},
332371
unitOptions() {
333-
return durationUnits.map((value) => ({
372+
const options = durationUnits.map((value) => ({
334373
value,
335374
name: this.fmtDurationUnit(2, value),
336375
}));
376+
// interval fields can also be driven by a cron expression
377+
if (this.cronCapable) {
378+
options.push({
379+
value: "cron",
380+
name: this.$t("config.form.cron"),
381+
});
382+
}
383+
return options;
337384
},
338385
selectOptions() {
339386
if (this.chargeModes) {
@@ -363,6 +410,10 @@ export default {
363410
},
364411
value: {
365412
get() {
413+
if (this.cronActive) {
414+
return this.modelValue ?? "";
415+
}
416+
366417
if (this.select && this.modelValue == null) {
367418
return "";
368419
}
@@ -399,6 +450,11 @@ export default {
399450
return this.modelValue;
400451
},
401452
set(value) {
453+
if (this.cronActive) {
454+
this.$emit("update:modelValue", value);
455+
return;
456+
}
457+
402458
let newValue = value;
403459
404460
if (this.scale) {
@@ -431,12 +487,22 @@ export default {
431487
return val;
432488
},
433489
onUnitChange(e) {
434-
// read display value before override changes the getter's unit
435-
const num = this.value;
436-
this.unitOverride = e.target.value;
437-
if (typeof num === "number") {
438-
this.$emit("update:modelValue", toGoDuration(num, this.unitOverride));
490+
const unit = e.target.value;
491+
492+
if (unit === "cron") {
493+
this.cronMode = true;
494+
this.$emit("update:modelValue", "");
495+
return;
496+
}
497+
498+
// a cron expression can't convert to a duration, so clear it
499+
if (this.cronActive) {
500+
this.cronMode = false;
501+
this.unitOverride = unit;
502+
this.$emit("update:modelValue", "");
503+
return;
439504
}
505+
this.unitOverride = unit;
440506
},
441507
onFieldChange(e) {
442508
// unparsable input (e.g. locale decimal separator mismatch)

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ require (
8282
github.com/prometheus/client_golang v1.24.1
8383
github.com/prometheus/common v0.70.1
8484
github.com/robertkrimen/otto v0.5.1
85+
github.com/robfig/cron/v3 v3.0.1
8586
github.com/samber/lo v1.53.0
8687
github.com/sandrolain/httpcache v1.4.2
8788
github.com/sethvargo/go-password v0.4.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,8 @@ github.com/rickb777/plural v1.4.7 h1:rBRAxp9aTFYzWTLWIE/UTwKcaqSSAV2ml7aOUFYpAGo
461461
github.com/rickb777/plural v1.4.7/go.mod h1:DB19dtrplGS5s6VJVHn7tvmFYPoE83p1xqio3oVnNRM=
462462
github.com/robertkrimen/otto v0.5.1 h1:avDI4ToRk8k1hppLdYFTuuzND41n37vPGJU7547dGf0=
463463
github.com/robertkrimen/otto v0.5.1/go.mod h1:bS433I4Q9p+E5pZLu7r17vP6FkE6/wLxBdmKjoqJXF8=
464+
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
465+
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
464466
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
465467
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
466468
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=

i18n/de.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,8 @@
314314
"titleEdit": "Zusätzlichen Zähler bearbeiten"
315315
},
316316
"form": {
317+
"cron": "Cron",
318+
"cronInvalid": "Cron-Ausdruck wie 15 0 * * * oder @daily eingeben, keine Dauer",
317319
"danger": "Achtung",
318320
"deprecated": "veraltet",
319321
"durationUnit": "Zeiteinheit für {label}",

i18n/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,8 @@
314314
"titleEdit": "Edit Additional Meter"
315315
},
316316
"form": {
317+
"cron": "cron",
318+
"cronInvalid": "Enter a cron expression like 15 0 * * * or @daily, not a duration",
317319
"danger": "Danger",
318320
"deprecated": "deprecated",
319321
"durationUnit": "Time unit for {label}",

tariff/amber.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type Amber struct {
2323
uri string
2424
channel string
2525
data *util.Monitor[api.Rates]
26+
timer *refreshTimer
2627
}
2728

2829
var _ api.Tariff = (*Amber)(nil)
@@ -33,10 +34,11 @@ func init() {
3334

3435
func NewAmberFromConfig(other map[string]any) (api.Tariff, error) {
3536
var cc struct {
36-
embed `mapstructure:",squash"`
37-
Token string
38-
SiteID string
39-
Channel string
37+
embed `mapstructure:",squash"`
38+
schedule `mapstructure:",squash"`
39+
Token string
40+
SiteID string
41+
Channel string
4042
}
4143

4244
if err := util.DecodeOther(other, &cc); err != nil {
@@ -55,6 +57,11 @@ func NewAmberFromConfig(other map[string]any) (api.Tariff, error) {
5557
return nil, errors.New("missing channel")
5658
}
5759

60+
timer, err := cc.schedule.timer(time.Minute)
61+
if err != nil {
62+
return nil, err
63+
}
64+
5865
log := util.NewLogger("amber").Redact(cc.Token)
5966

6067
t := &Amber{
@@ -63,7 +70,8 @@ func NewAmberFromConfig(other map[string]any) (api.Tariff, error) {
6370
Helper: request.NewHelper(log),
6471
uri: fmt.Sprintf(amber.URI, strings.ToUpper(cc.SiteID)),
6572
channel: strings.ToLower(cc.Channel),
66-
data: util.NewMonitor[api.Rates](2 * time.Hour),
73+
data: util.NewMonitor[api.Rates](max(2*time.Hour, timer.window())),
74+
timer: timer,
6775
}
6876

6977
t.Client.Transport = &transport.Decorator{
@@ -79,7 +87,7 @@ func NewAmberFromConfig(other map[string]any) (api.Tariff, error) {
7987
func (t *Amber) run(done chan error) {
8088
var once sync.Once
8189

82-
for tick := time.Tick(time.Minute); ; <-tick {
90+
for tick := t.timer.C(); ; <-tick {
8391
var res []amber.PriceInfo
8492

8593
if err := backoff.Retry(func() error {

tariff/awattar.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ import (
1616

1717
type Awattar struct {
1818
*embed
19-
log *util.Logger
20-
uri string
21-
data *util.Monitor[api.Rates]
19+
log *util.Logger
20+
uri string
21+
data *util.Monitor[api.Rates]
22+
timer *refreshTimer
2223
}
2324

2425
var _ api.Tariff = (*Awattar)(nil)
@@ -29,8 +30,9 @@ func init() {
2930

3031
func NewAwattarFromConfig(other map[string]any) (api.Tariff, error) {
3132
cc := struct {
32-
embed `mapstructure:",squash"`
33-
Region string
33+
embed `mapstructure:",squash"`
34+
schedule `mapstructure:",squash"`
35+
Region string
3436
}{
3537
Region: "DE",
3638
}
@@ -43,11 +45,17 @@ func NewAwattarFromConfig(other map[string]any) (api.Tariff, error) {
4345
return nil, err
4446
}
4547

48+
timer, err := cc.schedule.timer(time.Hour)
49+
if err != nil {
50+
return nil, err
51+
}
52+
4653
t := &Awattar{
4754
embed: &cc.embed,
4855
log: util.NewLogger("awattar"),
4956
uri: fmt.Sprintf(awattar.RegionURI, strings.ToLower(cc.Region)),
50-
data: util.NewMonitor[api.Rates](2 * time.Hour),
57+
data: util.NewMonitor[api.Rates](max(2*time.Hour, timer.window())),
58+
timer: timer,
5159
}
5260

5361
return runOrError(t)
@@ -58,7 +66,7 @@ func (t *Awattar) run(done chan error) {
5866

5967
client := request.NewHelper(t.log)
6068

61-
for tick := time.Tick(time.Hour); ; <-tick {
69+
for tick := t.timer.C(); ; <-tick {
6270
var res awattar.Prices
6371

6472
// Awattar publishes prices for next day around 13:00 CET/CEST, so up to 35h of price data are available

tariff/edf-tempo.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type EdfTempo struct {
2727
basic string
2828
data *util.Monitor[api.Rates]
2929
prices map[string]float64
30+
timer *refreshTimer
3031
}
3132

3233
var _ api.Tariff = (*EdfTempo)(nil)
@@ -38,6 +39,7 @@ func init() {
3839
func NewEdfTempoFromConfig(other map[string]any) (api.Tariff, error) {
3940
var cc struct {
4041
embed `mapstructure:",squash"`
42+
schedule `mapstructure:",squash"`
4143
ClientID string
4244
ClientSecret string
4345
Prices struct {
@@ -57,6 +59,11 @@ func NewEdfTempoFromConfig(other map[string]any) (api.Tariff, error) {
5759
return nil, err
5860
}
5961

62+
timer, err := cc.schedule.timer(time.Hour)
63+
if err != nil {
64+
return nil, err
65+
}
66+
6067
basic := transport.BasicAuthHeader(cc.ClientID, cc.ClientSecret)
6168
log := util.NewLogger("edf-tempo").Redact(basic)
6269

@@ -65,7 +72,8 @@ func NewEdfTempoFromConfig(other map[string]any) (api.Tariff, error) {
6572
log: log,
6673
basic: basic,
6774
Helper: request.NewHelper(log),
68-
data: util.NewMonitor[api.Rates](2 * time.Hour),
75+
data: util.NewMonitor[api.Rates](max(2*time.Hour, timer.window())),
76+
timer: timer,
6977
}
7078

7179
prices := structs.Map(cc.Prices)
@@ -103,7 +111,7 @@ func (t *EdfTempo) refreshToken() (*oauth2.Token, error) {
103111
func (t *EdfTempo) run(done chan error) {
104112
var once sync.Once
105113

106-
for tick := time.Tick(time.Hour); ; <-tick {
114+
for tick := t.timer.C(); ; <-tick {
107115
var res struct {
108116
Data struct {
109117
Values []struct {

0 commit comments

Comments
 (0)