Skip to content

Commit 3f12b22

Browse files
authored
Controller IO Heating Room (#2950)
Controls electric floor and infrared heating in a room. The Controller activates electric floor and infrared heating in a room in order to keep a configured LOW or HIGH temperature. The timings of LOW or HIGH are configured via a `schedule` configuration parameter. Additionally: - Improvements to JSCalendar (this is the first Controller that uses a JSCalendar schedule configuration... more to come!) - Improvements to core components (JsonUtils, Thermometer, DummyThermometer, DummyInputOutput, etc.)
1 parent 2574b22 commit 3f12b22

File tree

28 files changed

+1849
-105
lines changed

28 files changed

+1849
-105
lines changed

io.openems.common/src/io/openems/common/jscalendar/JSCalendar.java

+151-60
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33
import static com.google.common.collect.ImmutableList.toImmutableList;
44
import static com.google.common.collect.ImmutableSortedSet.toImmutableSortedSet;
5-
import static io.openems.common.jscalendar.JSCalendar.RecurrenceFrequency.WEEKLY;
65
import static io.openems.common.utils.JsonUtils.getAsEnum;
7-
import static io.openems.common.utils.JsonUtils.getAsJsonArray;
86
import static io.openems.common.utils.JsonUtils.getAsJsonObject;
9-
import static io.openems.common.utils.JsonUtils.getAsLocalDateTime;
7+
import static io.openems.common.utils.JsonUtils.getAsOptionalJsonArray;
8+
import static io.openems.common.utils.JsonUtils.getAsOptionalJsonObject;
9+
import static io.openems.common.utils.JsonUtils.getAsOptionalString;
10+
import static io.openems.common.utils.JsonUtils.getAsOptionalUUID;
11+
import static io.openems.common.utils.JsonUtils.getAsOptionalZonedDateTime;
1012
import static io.openems.common.utils.JsonUtils.getAsString;
11-
import static io.openems.common.utils.JsonUtils.getAsUUID;
12-
import static io.openems.common.utils.JsonUtils.getAsZonedDateTime;
13+
import static io.openems.common.utils.JsonUtils.parseToJsonArray;
1314
import static io.openems.common.utils.JsonUtils.stream;
1415
import static io.openems.common.utils.JsonUtils.toJsonArray;
1516
import static java.time.DayOfWeek.FRIDAY;
@@ -19,16 +20,22 @@
1920
import static java.time.DayOfWeek.THURSDAY;
2021
import static java.time.DayOfWeek.TUESDAY;
2122
import static java.time.DayOfWeek.WEDNESDAY;
23+
import static java.time.LocalDate.EPOCH;
2224
import static java.time.format.DateTimeFormatter.ISO_INSTANT;
2325
import static java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME;
2426
import static java.time.temporal.ChronoField.NANO_OF_DAY;
2527
import static java.time.temporal.TemporalAdjusters.nextOrSame;
2628
import static java.util.Arrays.stream;
27-
import static java.util.UUID.randomUUID;
2829

2930
import java.time.DayOfWeek;
31+
import java.time.Duration;
32+
import java.time.LocalDate;
3033
import java.time.LocalDateTime;
34+
import java.time.LocalTime;
3135
import java.time.ZonedDateTime;
36+
import java.time.format.DateTimeFormatter;
37+
import java.time.format.DateTimeParseException;
38+
import java.time.temporal.ChronoUnit;
3239
import java.util.NoSuchElementException;
3340
import java.util.UUID;
3441
import java.util.function.Consumer;
@@ -58,9 +65,33 @@
5865
public class JSCalendar<PAYLOAD> {
5966
// CHECKSTYLE:ON
6067

61-
public static record Task<PAYLOAD>(UUID uid, ZonedDateTime updated, LocalDateTime start,
68+
private static final String PROPERTY_PAYLOAD = "openems.io:payload";
69+
70+
public static record Task<PAYLOAD>(UUID uid, ZonedDateTime updated, LocalDateTime start, Duration duration,
6271
ImmutableList<RecurrenceRule> recurrenceRules, PAYLOAD payload) {
6372

73+
/**
74+
* Parse a List of {@link Task}s from a String representing a {@link JsonArray}
75+
* - includes checks for null and empty.
76+
*
77+
* @param <PAYLOAD> the type of the Payload
78+
* @param string the {@link JsonArray} string
79+
* @param payloadParser a parser for a Payload
80+
* @return the List of {@link Task}s
81+
*/
82+
public static <PAYLOAD> ImmutableList<Task<PAYLOAD>> fromStringOrEmpty(String string,
83+
ThrowingFunction<JsonObject, PAYLOAD, OpenemsNamedException> payloadParser) {
84+
if (string == null || string.isBlank()) {
85+
return ImmutableList.of();
86+
}
87+
try {
88+
return fromJson(parseToJsonArray(string), payloadParser);
89+
} catch (OpenemsNamedException e) {
90+
e.printStackTrace();
91+
return ImmutableList.of();
92+
}
93+
}
94+
6495
/**
6596
* Parse a List of {@link Task}s from a {@link JsonArray}.
6697
*
@@ -76,7 +107,7 @@ public static <PAYLOAD> ImmutableList<Task<PAYLOAD>> fromJson(JsonArray json,
76107
return stream(json) //
77108
.map(j -> {
78109
try {
79-
return fromJson(JsonUtils.getAsJsonObject(j), payloadParser);
110+
return fromJson(getAsJsonObject(j), payloadParser);
80111
} catch (OpenemsNamedException e) {
81112
e.printStackTrace();
82113
throw new NoSuchElementException(e.getMessage());
@@ -121,55 +152,68 @@ public static <PAYLOAD> Task<PAYLOAD> fromJson(JsonObject json,
121152
if (!type.equalsIgnoreCase("Task")) {
122153
throw new OpenemsException("This is not a 'Task': " + type);
123154
}
124-
try {
125-
var uid = getAsUUID(json, "uid");
126-
var updated = getAsZonedDateTime(json, "updated");
127-
var start = getAsLocalDateTime(json, "start");
128-
var recurrenceRules = stream(getAsJsonArray(json, "recurrenceRules")) //
129-
.map(r -> {
130-
try {
131-
return RecurrenceRule.fromJson(r);
132-
} catch (OpenemsNamedException e) {
133-
e.printStackTrace();
134-
throw new NoSuchElementException(e.getMessage());
135-
}
136-
}) //
137-
.collect(toImmutableList());
138-
var payload = payloadParser.apply(getAsJsonObject(json, "payload"));
139-
return new Task<PAYLOAD>(uid, updated, start, recurrenceRules, payload);
140-
141-
} catch (NoSuchElementException e) {
142-
throw new OpenemsException("NoSuchElementException: " + e.getMessage());
143-
}
155+
var b = Task.<PAYLOAD>create() //
156+
.setUid(getAsOptionalUUID(json, "uid").orElse(null)) //
157+
.setUpdated(getAsOptionalZonedDateTime(json, "updated").orElse(null)) //
158+
.setStart(getAsString(json, "start")) //
159+
.setDuration(getAsOptionalString(json, "duration").orElse(null)); //
160+
getAsOptionalJsonArray(json, "recurrenceRules") //
161+
.ifPresent(j -> stream(j) //
162+
.forEach(r -> b.addRecurrenceRule(r)));
163+
var rawPayload = getAsOptionalJsonObject(json, PROPERTY_PAYLOAD);
164+
b.setPayload(rawPayload.isPresent() //
165+
? payloadParser.apply(rawPayload.get()) //
166+
: null);
167+
return b.build();
144168
}
145169

146170
public static class Builder<PAYLOAD> {
147-
private final UUID uid;
148-
private final ZonedDateTime updated;
149-
171+
private UUID uid = null;
172+
private ZonedDateTime updated = null;
150173
private LocalDateTime start = null;
174+
private Duration duration = null;
151175
private ImmutableList.Builder<RecurrenceRule> recurrenceRules = ImmutableList.builder();
152176
private PAYLOAD payload = null;
153177

154178
protected Builder() {
155-
this(randomUUID(), ZonedDateTime.now());
156179
}
157180

158-
protected Builder(UUID uid, ZonedDateTime updated) {
181+
public Builder<PAYLOAD> setUid(UUID uid) {
159182
this.uid = uid;
183+
return this;
184+
}
185+
186+
public Builder<PAYLOAD> setUpdated(ZonedDateTime updated) {
160187
this.updated = updated;
188+
return this;
161189
}
162190

163191
public Builder<PAYLOAD> setStart(LocalDateTime start) {
164192
this.start = start;
165193
return this;
166194
}
167195

168-
protected Builder<PAYLOAD> setStart(String start) {
169-
this.setStart(LocalDateTime.parse(start));
196+
public Builder<PAYLOAD> setStart(LocalTime start) {
197+
return this.setStart(LocalDateTime.of(EPOCH, start));
198+
}
199+
200+
protected Builder<PAYLOAD> setStart(String start) throws DateTimeParseException {
201+
try {
202+
return this.setStart(LocalDateTime.parse(start));
203+
} catch (DateTimeParseException e) {
204+
return this.setStart(LocalTime.parse(start));
205+
}
206+
}
207+
208+
public Builder<PAYLOAD> setDuration(Duration duration) {
209+
this.duration = duration;
170210
return this;
171211
}
172212

213+
protected Builder<PAYLOAD> setDuration(String duration) {
214+
return this.setDuration(duration == null ? null : Duration.parse(duration));
215+
}
216+
173217
/**
174218
* Adds a {@link RecurrenceRule}.
175219
*
@@ -181,6 +225,21 @@ public Builder<PAYLOAD> addRecurrenceRule(RecurrenceRule recurrenceRule) {
181225
return this;
182226
}
183227

228+
/**
229+
* Adds a {@link RecurrenceRule}.
230+
*
231+
* @param json the {@link RecurrenceRule} as {@link JsonObject}
232+
* @return myself
233+
*/
234+
public Builder<PAYLOAD> addRecurrenceRule(JsonElement json) throws NoSuchElementException {
235+
try {
236+
return this.addRecurrenceRule(RecurrenceRule.fromJson(json));
237+
} catch (OpenemsNamedException e) {
238+
e.printStackTrace();
239+
throw new NoSuchElementException(e.getMessage());
240+
}
241+
}
242+
184243
/**
185244
* Adds a {@link RecurrenceRule}.
186245
*
@@ -200,8 +259,8 @@ public Builder<PAYLOAD> setPayload(PAYLOAD payload) {
200259
}
201260

202261
public Task<PAYLOAD> build() {
203-
return new Task<PAYLOAD>(this.uid, this.updated, this.start, this.recurrenceRules.build(),
204-
this.payload);
262+
return new Task<PAYLOAD>(this.uid, this.updated, this.start, this.duration,
263+
this.recurrenceRules.build(), this.payload);
205264
}
206265
}
207266

@@ -223,33 +282,48 @@ public static <PAYLOAD> Builder<PAYLOAD> create() {
223282
*/
224283
public JsonObject toJson(Function<PAYLOAD, JsonObject> payloadConverter) {
225284
var j = JsonUtils.buildJsonObject() //
226-
.addProperty("@type", "Task") //
227-
.addProperty("uid", this.uid.toString()) //
228-
.addProperty("updated", this.updated.format(ISO_INSTANT));
285+
.addProperty("@type", "Task");
286+
if (this.uid != null) {
287+
j.addProperty("uid", this.uid.toString());
288+
}
289+
if (this.updated != null) {
290+
j.addProperty("updated", this.updated.format(ISO_INSTANT));
291+
}
229292
if (this.start != null) {
230-
j.addProperty("start", this.start.format(ISO_LOCAL_DATE_TIME));
293+
if (LocalDate.from(this.start).equals(EPOCH)) {
294+
j.addProperty("start", this.start.format(DateTimeFormatter.ISO_LOCAL_TIME));
295+
} else {
296+
j.addProperty("start", this.start.format(ISO_LOCAL_DATE_TIME));
297+
}
298+
}
299+
if (this.duration != null) {
300+
j.addProperty("duration", this.duration.toString());
231301
}
232302
if (!this.recurrenceRules.isEmpty()) {
233303
j.add("recurrenceRules", this.recurrenceRules.stream() //
234304
.map(RecurrenceRule::toJson) //
235305
.collect(toJsonArray()));
236306
}
237307
if (this.payload != null) {
238-
j.add("payload", payloadConverter.apply(this.payload));
308+
j.add(PROPERTY_PAYLOAD, payloadConverter.apply(this.payload));
239309
}
240310
return j.build();
241311
}
242312

243313
/**
244-
* Gets the next occurence of the {@link Task} at or after a date.
314+
* Gets the next occurence of the {@link Task} (including duration) at or after
315+
* a date.
245316
*
246317
* @param from the from timestamp
247318
* @return a {@link ZonedDateTime}
248319
*/
249320
public ZonedDateTime getNextOccurence(ZonedDateTime from) {
321+
var f = this.duration == null //
322+
? from //
323+
: from.minus(this.duration); // query active tasks
250324
var start = this.start.atZone(from.getZone());
251325
return this.recurrenceRules.stream() //
252-
.map(rr -> rr.getNextOccurence(from.isBefore(start) ? start : from, start)) //
326+
.map(rr -> rr.getNextOccurence(f.isBefore(start) ? start : f, start)) //
253327
.min((o1, o2) -> o1.toInstant().compareTo(o2.toInstant())) //
254328
.orElse(null);
255329
}
@@ -282,18 +356,20 @@ public record RecurrenceRule(RecurrenceFrequency frequency, ImmutableSortedSet<D
282356
*/
283357
public static RecurrenceRule fromJson(JsonElement json) throws OpenemsNamedException, NoSuchElementException {
284358
var frequency = getAsEnum(RecurrenceFrequency.class, json, "frequency");
285-
var byDay = stream(getAsJsonArray(json, "byDay")) //
286-
.map(j -> switch (JsonUtils.getAsOptionalString(j).orElseThrow()) {
287-
case "mo" -> MONDAY;
288-
case "tu" -> TUESDAY;
289-
case "we" -> WEDNESDAY;
290-
case "th" -> THURSDAY;
291-
case "fr" -> FRIDAY;
292-
case "sa" -> SATURDAY;
293-
case "su" -> SUNDAY;
294-
default -> throw new NoSuchElementException("");
295-
}) //
296-
.collect(toImmutableSortedSet(Ordering.natural()));
359+
var byDay = getAsOptionalJsonArray(json, "byDay") //
360+
.map(arr -> stream(arr) //
361+
.map(j -> switch (getAsOptionalString(j).orElseThrow()) {
362+
case "mo" -> MONDAY;
363+
case "tu" -> TUESDAY;
364+
case "we" -> WEDNESDAY;
365+
case "th" -> THURSDAY;
366+
case "fr" -> FRIDAY;
367+
case "sa" -> SATURDAY;
368+
case "su" -> SUNDAY;
369+
default -> throw new NoSuchElementException("");
370+
}) //
371+
.collect(toImmutableSortedSet(Ordering.natural()))) //
372+
.orElse(ImmutableSortedSet.of());
297373
return new RecurrenceRule(frequency, byDay);
298374
}
299375

@@ -342,21 +418,36 @@ public static Builder create() {
342418
* @return a {@link ZonedDateTime}
343419
*/
344420
public ZonedDateTime getNextOccurence(ZonedDateTime from, ZonedDateTime start) {
345-
if (this.frequency == WEEKLY) {
421+
final var startTime = start.toLocalTime();
422+
423+
return switch (this.frequency) {
424+
case DAILY -> {
425+
var resultDay = from.truncatedTo(ChronoUnit.DAYS);
426+
if (from.toLocalTime().isAfter(startTime)) {
427+
resultDay = from.plusDays(1);
428+
}
429+
yield resultDay.with(NANO_OF_DAY, startTime.toNanoOfDay());
430+
}
431+
case WEEKLY -> {
346432
if (!this.byDay.isEmpty()) {
347-
var startTime = start.toLocalTime();
348433
var nextByDay = this.byDay.ceiling(from.toLocalTime().isAfter(startTime) //
349434
? from.getDayOfWeek().plus(1) // next day
350435
: from.getDayOfWeek()); // same day
351436
if (nextByDay == null) {
352437
nextByDay = this.byDay.first();
353438
}
354-
return from //
439+
yield from //
355440
.with(nextOrSame(nextByDay)) //
356441
.with(NANO_OF_DAY, startTime.toNanoOfDay());
357442
}
443+
// TODO: If frequency is weekly and there is no byDay property, add a byDay
444+
// property with the sole value being the day of the week of the initial
445+
// date-time.
446+
yield null; // not implemented
358447
}
359-
return null;
448+
case MONTHLY -> null; // not implemented
449+
case YEARLY -> null; // not implemented
450+
};
360451
}
361452

362453
/**

io.openems.common/src/io/openems/common/utils/JsonUtils.java

+13
Original file line numberDiff line numberDiff line change
@@ -1759,6 +1759,19 @@ public static LocalDateTime getAsLocalDateTime(JsonElement jElement, String memb
17591759
return DateUtils.parseLocalDateTimeOrError(toString(toPrimitive(toSubElement(jElement, memberName))));
17601760
}
17611761

1762+
/**
1763+
* Takes a JSON in the form '2020-01-01T00:00:00' and converts it to a
1764+
* {@link LocalDateTime}.
1765+
*
1766+
* @param jElement the {@link JsonElement}
1767+
* @param memberName the name of the member of the JsonObject
1768+
* @return the {@link ZonedDateTime}
1769+
*/
1770+
public static Optional<LocalDateTime> getAsOptionalLocalDateTime(JsonElement jElement, String memberName) {
1771+
return JsonUtils.getAsOptionalString(jElement, memberName)//
1772+
.map(DateUtils::parseLocalDateTimeOrNull);
1773+
}
1774+
17621775
/**
17631776
* Takes a JSON in the form '2020-01-01T00:00:00Z' and converts it to a
17641777
* {@link ZonedDateTime}.

0 commit comments

Comments
 (0)