diff --git a/application.properties b/application.properties
index 360b82a..a03b4d5 100644
--- a/application.properties
+++ b/application.properties
@@ -1 +1,11 @@
-logging.level.de.ph87=DEBUG
\ No newline at end of file
+logging.level.de.ph87=INFO
+logging.level.de.ph87.data.mqtt=DEBUG
+#-
+spring.datasource.url=jdbc:h2:./database;AUTO_SERVER=TRUE
+spring.datasource.driverClassName=org.h2.Driver
+#spring.jpa.hibernate.ddl-auto=create
+spring.jpa.hibernate.ddl-auto=update
+#-
+spring.jackson.serialization.indent_output=true
+#-
+de.ph87.knx.mqtt.uri=tcp://10.0.0.50:1883
diff --git a/pom.xml b/pom.xml
index 78fdd02..2e440a1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -38,10 +38,6 @@
org.springframework.security
spring-security-core
-
-
-
-
org.springframework.boot
spring-boot-starter-test
@@ -58,6 +54,11 @@
org.postgresql
postgresql
+
+ org.eclipse.paho
+ org.eclipse.paho.client.mqttv3
+ 1.2.5
+
\ No newline at end of file
diff --git a/src/main/java/de/ph87/data/DemoService.java b/src/main/java/de/ph87/data/DemoService.java
new file mode 100644
index 0000000..84b735a
--- /dev/null
+++ b/src/main/java/de/ph87/data/DemoService.java
@@ -0,0 +1,129 @@
+package de.ph87.data;
+
+import de.ph87.data.series.Series;
+import de.ph87.data.series.SeriesRepository;
+import de.ph87.data.series.SeriesType;
+import de.ph87.data.topic.Topic;
+import de.ph87.data.topic.TopicRepository;
+import de.ph87.data.topic.query.TopicQuery;
+import de.ph87.data.topic.query.TopicQueryFunction;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class DemoService {
+
+ private final SeriesRepository seriesRepository;
+
+ private final TopicRepository topicRepository;
+
+ @Transactional
+ @EventListener(ApplicationReadyEvent.class)
+ public void init() {
+ final Series electricityEnergyProduce = series("electricity/energy/produce", "kWh", SeriesType.DELTA, 5);
+ final Series electricityPowerProduce = series("electricity/power/produce", "W", SeriesType.VARYING, 5);
+ topic(
+ "openDTU/pv/patrix/json2",
+ new TopicQuery(electricityEnergyProduce, "$.totalKWh"),
+ new TopicQuery(electricityPowerProduce, "$.totalW")
+ );
+
+ final Series electricityEnergyPurchase = series("electricity/energy/purchase", "kWh", SeriesType.DELTA, 5);
+ final Series electricityPowerPurchase = series("electricity/power/purchase", "W", SeriesType.VARYING, 5);
+ final Series electricityEnergyDelivery = series("electricity/energy/delivery", "kWh", SeriesType.DELTA, 5);
+ final Series electricityPowerDelivery = series("electricity/power/delivery", "W", SeriesType.VARYING, 5);
+ topic(
+ "electricity/grid/json",
+ new TopicQuery(electricityEnergyPurchase, "$.purchaseWh", 0.001),
+ new TopicQuery(electricityPowerPurchase, "$.powerW", TopicQueryFunction.ONLY_POSITIVE),
+ new TopicQuery(electricityEnergyDelivery, "$.deliveryWh", 0.001),
+ new TopicQuery(electricityPowerDelivery, "$.powerW", TopicQueryFunction.ONLY_NEGATIVE_BUT_NEGATE)
+ );
+
+ final Series gardenPressure = series("garden/pressure", "hPa", SeriesType.VARYING, 5);
+ final Series gardenTemperature = series("garden/temperature", "°C", SeriesType.VARYING, 5);
+ final Series gardenHumidityAbsolute = series("garden/humidity/absolute", "mg/L", SeriesType.VARYING, 5);
+ final Series gardenHumidityRelative = series("garden/humidity/relative", "%", SeriesType.VARYING, 5);
+ topic("garten/sensor/pressure", new TopicQuery(gardenPressure, "$.value"));
+ topic("garten/sensor/temperature", new TopicQuery(gardenTemperature, "$.value"));
+ topic("garten/sensor/humidity_absolute", new TopicQuery(gardenHumidityAbsolute, "$.value"));
+ topic("garten/sensor/humidity_relative", new TopicQuery(gardenHumidityRelative, "$.value"));
+
+ final Series bedroomPressure = series("bedroom/pressure", "hPa", SeriesType.VARYING, 5);
+ final Series bedroomTemperature = series("bedroom/temperature", "°C", SeriesType.VARYING, 5);
+ final Series bedroomHumidityAbsolute = series("bedroom/humidity/absolute", "mg/L", SeriesType.VARYING, 5);
+ final Series bedroomHumidityRelative = series("bedroom/humidity/relative", "%", SeriesType.VARYING, 5);
+ topic("schlafzimmer/sensor/pressure", new TopicQuery(bedroomPressure, "$.value"));
+ topic("schlafzimmer/sensor/temperature", new TopicQuery(bedroomTemperature, "$.value"));
+ topic("schlafzimmer/sensor/humidity_absolute", new TopicQuery(bedroomHumidityAbsolute, "$.value"));
+ topic("schlafzimmer/sensor/humidity_relative", new TopicQuery(bedroomHumidityRelative, "$.value"));
+
+ final Series basementTemperature = series("basement/temperature", "°C", SeriesType.VARYING, 60);
+ final Series basementHumidityAbsolute = series("basement/humidity/absolute", "mg/L", SeriesType.VARYING, 60);
+ final Series basementHumidityRelative = series("basement/humidity/relative", "%", SeriesType.VARYING, 60);
+ topic("aggregation/heizraum/luftfeuchte/absolut", "$.lastTime", new TopicQuery(basementTemperature, "$.lastValue"));
+ topic("aggregation/heizraum/luftfeuchte/relativ", "$.lastTime", new TopicQuery(basementHumidityAbsolute, "$.lastValue"));
+ topic("aggregation/heizraum/temperatur", "$.lastTime", new TopicQuery(basementHumidityRelative, "$.lastValue"));
+
+ final Series heatingExhaustTemperature = series("heating/exhaust/temperature", "°C", SeriesType.VARYING, 60);
+ topic("aggregation/heizung/abgas/temperatur", "$.lastTime", new TopicQuery(heatingExhaustTemperature, "$.lastValue"));
+
+ final Series heatingCircuitReturnTemperature = series("heating/circuit/return/temperature", "°C", SeriesType.VARYING, 60);
+ final Series heatingCircuitSupplyTemperature = series("heating/circuit/supply/temperature", "°C", SeriesType.VARYING, 60);
+ topic("aggregation/heizung/heizkreis/ruecklauf/temperatur", "$.lastTime", new TopicQuery(heatingCircuitReturnTemperature, "$.lastValue"));
+ topic("aggregation/heizung/heizkreis/vorlauf/temperatur", "$.lastTime", new TopicQuery(heatingCircuitSupplyTemperature, "$.lastValue"));
+
+ final Series heatingBufferInletTemperature = series("heating/buffer/inlet/temperature", "°C", SeriesType.VARYING, 60);
+ final Series heatingBufferOutletTemperature = series("heating/buffer/outlet/temperature", "°C", SeriesType.VARYING, 60);
+ final Series heatingBufferCirculationTemperature = series("heating/buffer/circulation/temperature", "°C", SeriesType.VARYING, 60);
+ topic("aggregation/heizung/puffer/eingang/temperatur", "$.lastTime", new TopicQuery(heatingBufferInletTemperature, "$.lastValue"));
+ topic("aggregation/heizung/puffer/ausgang/temperatur", "$.lastTime", new TopicQuery(heatingBufferOutletTemperature, "$.lastValue"));
+ topic("aggregation/heizung/puffer/zirkulation/temperatur", "$.lastTime", new TopicQuery(heatingBufferCirculationTemperature, "$.lastValue"));
+
+ final Series heatingBufferInsideTemperature = series("heating/buffer/inside/temperature", "°C", SeriesType.VARYING, 60);
+ topic("aggregation/heizung/puffer/speicher/temperatur", "$.lastTime", new TopicQuery(heatingBufferInsideTemperature, "$.lastValue"));
+
+ final Series heatingBufferSupplyTemperature = series("heating/buffer/supply/temperature", "°C", SeriesType.VARYING, 60);
+ final Series heatingBufferReturnTemperature = series("heating/buffer/return/temperature", "°C", SeriesType.VARYING, 60);
+ topic("aggregation/heizung/puffer/vorlauf/temperatur", "$.lastTime", new TopicQuery(heatingBufferSupplyTemperature, "$.lastValue"));
+ topic("aggregation/heizung/puffer/ruecklauf/temperatur", "$.lastTime", new TopicQuery(heatingBufferReturnTemperature, "$.lastValue"));
+
+ final Series cisternVolume = series("cistern/volume", "L", SeriesType.VARYING, 5);
+ topic("cistern/volume/PatrixJson", "$.date", new TopicQuery(cisternVolume, "$.value"));
+ }
+
+ @NonNull
+ private Series series(@NonNull final String name, @NonNull final String unit, @NonNull final SeriesType type, final int expectedEverySeconds) {
+ return seriesRepository
+ .findByName(name)
+ .stream()
+ .peek(existing -> {
+ existing.setUnit(unit);
+ existing.setType(type);
+ existing.setExpectedEverySeconds(expectedEverySeconds);
+ })
+ .findFirst()
+ .orElseGet(() -> seriesRepository.save(new Series(name, unit, 1, expectedEverySeconds, type)));
+ }
+
+ private void topic(@NonNull final String name, @NonNull final TopicQuery... queries) {
+ topic(name, "$.timestamp", queries);
+ }
+
+ private void topic(@NonNull final String name, @NonNull final String timestampQuery, @NonNull final TopicQuery... queries) {
+ final Topic topic = topicRepository.findByName(name).orElseGet(() -> topicRepository.save(new Topic(name)));
+ topic.setTimestampQuery(timestampQuery);
+ topic.getQueries().clear();
+ topic.getQueries().addAll(List.of(queries));
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/user/Helpers.java b/src/main/java/de/ph87/data/Helpers.java
similarity index 57%
rename from src/main/java/de/ph87/data/user/Helpers.java
rename to src/main/java/de/ph87/data/Helpers.java
index 5a0160c..30e1c5e 100644
--- a/src/main/java/de/ph87/data/user/Helpers.java
+++ b/src/main/java/de/ph87/data/Helpers.java
@@ -1,4 +1,4 @@
-package de.ph87.data.user;
+package de.ph87.data;
import jakarta.annotation.Nullable;
import lombok.NonNull;
@@ -8,11 +8,11 @@ import java.util.function.Function;
public class Helpers {
@Nullable
- public static R map(@Nullable T t, @NonNull final Function map) {
+ public static R map(@Nullable final T t, @NonNull final Function mapper) {
if (t == null) {
return null;
}
- return map.apply(t);
+ return mapper.apply(t);
}
}
diff --git a/src/main/java/de/ph87/data/log/AbstractEntityLog.java b/src/main/java/de/ph87/data/log/AbstractEntityLog.java
new file mode 100644
index 0000000..1e3e5f4
--- /dev/null
+++ b/src/main/java/de/ph87/data/log/AbstractEntityLog.java
@@ -0,0 +1,51 @@
+package de.ph87.data.log;
+
+import jakarta.annotation.Nullable;
+import jakarta.persistence.ElementCollection;
+import jakarta.persistence.MappedSuperclass;
+import jakarta.persistence.OrderColumn;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.NonNull;
+import lombok.ToString;
+import org.slf4j.Logger;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.ArrayList;
+import java.util.List;
+
+@Getter
+@ToString
+@MappedSuperclass
+@NoArgsConstructor
+public abstract class AbstractEntityLog {
+
+ @NonNull
+ @OrderColumn
+ @ToString.Exclude
+ @ElementCollection
+ private List log = new ArrayList<>();
+
+ public void error(@NonNull final Logger logger, @NonNull final String message) {
+ error(logger, message, null);
+ }
+
+ public void error(@NonNull final Logger logger, @NonNull final String message, @Nullable final Exception e) {
+ if (e instanceof RuntimeException) {
+ this.log.add(new LogMessage(LogSeverity.ERROR, message + "\n stacktrace:\n" + stacktraceToString(e)));
+ logger.error(message, e);
+ } else {
+ this.log.add(new LogMessage(LogSeverity.ERROR, message));
+ logger.error(message);
+ }
+ }
+
+ @NonNull
+ public static String stacktraceToString(@NonNull final Exception e) {
+ final StringWriter sw = new StringWriter();
+ e.printStackTrace(new PrintWriter(sw));
+ return sw.toString();
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/log/LogMessage.java b/src/main/java/de/ph87/data/log/LogMessage.java
new file mode 100644
index 0000000..aed56a1
--- /dev/null
+++ b/src/main/java/de/ph87/data/log/LogMessage.java
@@ -0,0 +1,38 @@
+package de.ph87.data.log;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Embeddable;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.NonNull;
+import lombok.ToString;
+
+import java.time.ZonedDateTime;
+
+@Getter
+@ToString
+@Embeddable
+@NoArgsConstructor
+public class LogMessage {
+
+ @NonNull
+ @Column(nullable = false)
+ private ZonedDateTime date;
+
+ @NonNull
+ @Column(nullable = false)
+ @Enumerated(EnumType.STRING)
+ private LogSeverity severity;
+
+ @NonNull
+ @Column(nullable = false)
+ private String message;
+
+ public LogMessage(@NonNull final LogSeverity severity, @NonNull final String message) {
+ this.severity = severity;
+ this.message = message;
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/log/LogSeverity.java b/src/main/java/de/ph87/data/log/LogSeverity.java
new file mode 100644
index 0000000..5ffee34
--- /dev/null
+++ b/src/main/java/de/ph87/data/log/LogSeverity.java
@@ -0,0 +1,5 @@
+package de.ph87.data.log;
+
+public enum LogSeverity {
+ ERROR, WARN, INFO, DEBUG
+}
diff --git a/src/main/java/de/ph87/data/mqtt/MqttInbound.java b/src/main/java/de/ph87/data/mqtt/MqttInbound.java
new file mode 100644
index 0000000..b24daaa
--- /dev/null
+++ b/src/main/java/de/ph87/data/mqtt/MqttInbound.java
@@ -0,0 +1,25 @@
+package de.ph87.data.mqtt;
+
+import lombok.Data;
+import lombok.NonNull;
+
+import java.time.ZonedDateTime;
+
+@Data
+public class MqttInbound {
+
+ @NonNull
+ public final ZonedDateTime date = ZonedDateTime.now();
+
+ @NonNull
+ public final String topic;
+
+ @NonNull
+ public final String payload;
+
+ public MqttInbound(@NonNull final String topic, @NonNull final String payload) {
+ this.topic = topic;
+ this.payload = payload;
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/mqtt/MqttService.java b/src/main/java/de/ph87/data/mqtt/MqttService.java
new file mode 100644
index 0000000..85193b5
--- /dev/null
+++ b/src/main/java/de/ph87/data/mqtt/MqttService.java
@@ -0,0 +1,89 @@
+package de.ph87.data.mqtt;
+
+import de.ph87.data.topic.TopicReceiver;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.eclipse.paho.client.mqttv3.MqttClient;
+import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
+import org.eclipse.paho.client.mqttv3.MqttException;
+import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
+import org.springframework.stereotype.Service;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class MqttService {
+
+ private final TopicReceiver topicReceiver;
+
+ private final Object lock = new Object();
+
+ private boolean stop = false;
+
+ @PostConstruct
+ public void init() {
+ new Thread(this::stayConnected).start();
+ }
+
+ @PreDestroy
+ public void destroy() {
+ synchronized (lock) {
+ stop = true;
+ lock.notifyAll();
+ }
+ }
+
+ private void stayConnected() {
+ try {
+ while (!stop) {
+ connectOnce();
+ }
+ } catch (InterruptedException e) {
+ log.error("Interrupted while waiting for connection", e);
+ } finally {
+ log.info("MQTT client stopped.");
+ }
+ }
+
+ private void connectOnce() throws InterruptedException {
+ MqttClient client = null;
+ try {
+ log.info("MQTT connecting...");
+ client = new MqttClient("tcp://10.0.0.50:1883", "DataDynamic", new MemoryPersistence());
+ final MqttConnectOptions options = new MqttConnectOptions();
+ options.setAutomaticReconnect(false);
+ options.setCleanSession(true);
+ options.setConnectionTimeout(5);
+ options.setKeepAliveInterval(2);
+ client.connect(options);
+ client.subscribe("#", (topic, message) -> topicReceiver.receive(new MqttInbound(topic, new String(message.getPayload()))));
+ log.info("MQTT connected.");
+ synchronized (lock) {
+ while (!stop && client.isConnected()) {
+ lock.wait(1000);
+ }
+ if (!client.isConnected()) {
+ log.info("MQTT disconnected.");
+ }
+ }
+ } catch (MqttException e) {
+ log.error("MQTT connection error: {}", e.getMessage());
+ synchronized (lock) {
+ lock.wait(3000);
+ }
+ } finally {
+ if (client != null && client.isConnected()) {
+ log.info("MQTT disconnecting...");
+ try {
+ client.disconnect();
+ log.info("MQTT disconnected");
+ } catch (MqttException e) {
+ log.error("Failed to cleanup connection: {}", e.getMessage());
+ }
+ }
+ }
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/series/Series.java b/src/main/java/de/ph87/data/series/Series.java
new file mode 100644
index 0000000..bbdd535
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/Series.java
@@ -0,0 +1,84 @@
+package de.ph87.data.series;
+
+import de.ph87.data.log.AbstractEntityLog;
+import jakarta.annotation.Nullable;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Version;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.NonNull;
+import lombok.Setter;
+import lombok.ToString;
+
+import java.time.ZonedDateTime;
+
+@Entity
+@Getter
+@ToString
+@NoArgsConstructor
+public class Series extends AbstractEntityLog {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private long id;
+
+ @Version
+ private long version;
+
+ @NonNull
+ @Column(nullable = false, unique = true)
+ private String name;
+
+ @Setter
+ @NonNull
+ @Column(nullable = false)
+ private String unit;
+
+ @Setter
+ @Column(nullable = false)
+ private int decimals;
+
+ @Column
+ @Nullable
+ private ZonedDateTime first = null;
+
+ @Column
+ @Nullable
+ private ZonedDateTime last = null;
+
+ @Nullable
+ @Column(name = "`value`")
+ private Double value = null;
+
+ @Setter
+ @Column(nullable = false)
+ private int expectedEverySeconds = 5;
+
+ @Setter
+ @NonNull
+ @Column(nullable = false)
+ @Enumerated(EnumType.STRING)
+ private SeriesType type;
+
+ public Series(@NonNull final String name, @NonNull final String unit, final int decimals, final int expectedEverySeconds, @NonNull final SeriesType type) {
+ this.name = name;
+ this.unit = unit;
+ this.decimals = decimals;
+ this.expectedEverySeconds = expectedEverySeconds;
+ this.type = type;
+ }
+
+ public void update(@NonNull final ZonedDateTime timestamp, final double value) {
+ if (this.last == null || this.last.isBefore(timestamp)) {
+ this.last = timestamp;
+ this.value = value;
+ }
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/series/SeriesController.java b/src/main/java/de/ph87/data/series/SeriesController.java
new file mode 100644
index 0000000..60feeab
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/SeriesController.java
@@ -0,0 +1,39 @@
+package de.ph87.data.series;
+
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.CrossOrigin;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@CrossOrigin
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("Series")
+public class SeriesController {
+
+ private final SeriesRepository seriesRepository;
+
+ private final SeriesService seriesService;
+
+ @GetMapping("findAll")
+ public List findAll() {
+ return seriesRepository.findAllDto();
+ }
+
+ @PostMapping("findByName")
+ public SeriesDto findByName(@NonNull @RequestBody final String name) {
+ return seriesRepository.findDtoByName(name);
+ }
+
+ @PostMapping("points")
+ public List extends SeriesPoint> points(@NonNull @RequestBody final SeriesPointsRequest request) {
+ return seriesService.points(request);
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/series/SeriesDto.java b/src/main/java/de/ph87/data/series/SeriesDto.java
new file mode 100644
index 0000000..2a9c2f4
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/SeriesDto.java
@@ -0,0 +1,48 @@
+package de.ph87.data.series;
+
+import de.ph87.data.websocket.IWebsocketMessage;
+import jakarta.annotation.Nullable;
+import lombok.Data;
+import lombok.NonNull;
+
+import java.time.ZonedDateTime;
+
+@Data
+public class SeriesDto implements IWebsocketMessage {
+
+ public final long id;
+
+ public final String name;
+
+ @NonNull
+ public final String unit;
+
+ public final int decimals;
+
+ @Nullable
+ public final ZonedDateTime first;
+
+ @Nullable
+ public final ZonedDateTime last;
+
+ @Nullable
+ public final Double value;
+
+ public final int expectedEverySeconds;
+
+ @NonNull
+ public final SeriesType type;
+
+ public SeriesDto(@NonNull final Series series) {
+ this.id = series.getId();
+ this.name = series.getName();
+ this.unit = series.getUnit();
+ this.decimals = series.getDecimals();
+ this.first = series.getFirst();
+ this.last = series.getLast();
+ this.value = series.getValue();
+ this.expectedEverySeconds = series.getExpectedEverySeconds();
+ this.type = series.getType();
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/series/SeriesPoint.java b/src/main/java/de/ph87/data/series/SeriesPoint.java
new file mode 100644
index 0000000..94fc92e
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/SeriesPoint.java
@@ -0,0 +1,13 @@
+package de.ph87.data.series;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+
+import java.io.IOException;
+
+@JsonSerialize(using = SeriesPointSerializer.class)
+public interface SeriesPoint {
+
+ void toJson(final JsonGenerator jsonGenerator) throws IOException;
+
+}
diff --git a/src/main/java/de/ph87/data/series/SeriesPointSerializer.java b/src/main/java/de/ph87/data/series/SeriesPointSerializer.java
new file mode 100644
index 0000000..b4e0e57
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/SeriesPointSerializer.java
@@ -0,0 +1,18 @@
+package de.ph87.data.series;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+
+import java.io.IOException;
+
+public class SeriesPointSerializer extends JsonSerializer {
+
+ @Override
+ public void serialize(final SeriesPoint seriesPoint, final JsonGenerator jsonGenerator, final SerializerProvider serializerProvider) throws IOException {
+ jsonGenerator.writeStartArray();
+ seriesPoint.toJson(jsonGenerator);
+ jsonGenerator.writeEndArray();
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/series/SeriesPointsRequest.java b/src/main/java/de/ph87/data/series/SeriesPointsRequest.java
new file mode 100644
index 0000000..1d349f3
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/SeriesPointsRequest.java
@@ -0,0 +1,45 @@
+package de.ph87.data.series;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import de.ph87.data.series.data.Interval;
+import lombok.Data;
+import lombok.NonNull;
+
+import java.time.ZonedDateTime;
+
+@Data
+public class SeriesPointsRequest {
+
+ public final long id;
+
+ @NonNull
+ public final Interval interval;
+
+ public final long offset;
+
+ public final long duration;
+
+ @NonNull
+ @JsonIgnore
+ public final ZonedDateTime first;
+
+ @NonNull
+ @JsonIgnore
+ public final ZonedDateTime after;
+
+ public SeriesPointsRequest(
+ @JsonProperty("id") final long id,
+ @JsonProperty("interval") final Interval interval,
+ @JsonProperty("offset") final long offset,
+ @JsonProperty("duration") final long duration
+ ) {
+ this.id = id;
+ this.interval = interval;
+ this.offset = offset;
+ this.duration = duration;
+ this.after = interval.align.apply(ZonedDateTime.now()).minus(interval.amount * (offset - 1), interval.unit);
+ this.first = this.after.minus(interval.amount * duration, interval.unit);
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/series/SeriesRepository.java b/src/main/java/de/ph87/data/series/SeriesRepository.java
new file mode 100644
index 0000000..2cd8e89
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/SeriesRepository.java
@@ -0,0 +1,21 @@
+package de.ph87.data.series;
+
+import lombok.NonNull;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.ListCrudRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface SeriesRepository extends ListCrudRepository {
+
+ @NonNull
+ Optional findByName(@NonNull String seriesName);
+
+ @Query("select new de.ph87.data.series.SeriesDto(s) from Series s where s.name = :name")
+ SeriesDto findDtoByName(@NonNull String name);
+
+ @Query("select new de.ph87.data.series.SeriesDto(t) from Series t")
+ List findAllDto();
+
+}
diff --git a/src/main/java/de/ph87/data/series/SeriesService.java b/src/main/java/de/ph87/data/series/SeriesService.java
new file mode 100644
index 0000000..01545a5
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/SeriesService.java
@@ -0,0 +1,38 @@
+package de.ph87.data.series;
+
+import de.ph87.data.series.data.bool.BoolService;
+import de.ph87.data.series.data.delta.DeltaService;
+import de.ph87.data.series.data.varying.VaryingService;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.util.List;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SeriesService {
+
+ private final SeriesRepository seriesRepository;
+
+ private final BoolService boolService;
+
+ private final DeltaService deltaService;
+
+ private final VaryingService varyingService;
+
+ @NonNull
+ public List extends SeriesPoint> points(@NonNull final SeriesPointsRequest request) {
+ final Series series = seriesRepository.findById(request.id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
+ return switch (series.getType()) {
+ case BOOL -> boolService.points(series, request);
+ case DELTA -> deltaService.points(series, request);
+ case VARYING -> varyingService.points(series, request);
+ };
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/series/SeriesType.java b/src/main/java/de/ph87/data/series/SeriesType.java
new file mode 100644
index 0000000..c8c40f9
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/SeriesType.java
@@ -0,0 +1,5 @@
+package de.ph87.data.series;
+
+public enum SeriesType {
+ BOOL, DELTA, VARYING
+}
diff --git a/src/main/java/de/ph87/data/series/data/DataId.java b/src/main/java/de/ph87/data/series/data/DataId.java
new file mode 100644
index 0000000..4e0d2ee
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/DataId.java
@@ -0,0 +1,40 @@
+package de.ph87.data.series.data;
+
+import de.ph87.data.series.Series;
+import jakarta.persistence.Column;
+import jakarta.persistence.Embeddable;
+import jakarta.persistence.ManyToOne;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.NonNull;
+import lombok.ToString;
+
+import java.time.ZonedDateTime;
+
+@Getter
+@ToString
+@Embeddable
+@EqualsAndHashCode
+@NoArgsConstructor
+public class DataId {
+
+ @NonNull
+ @ManyToOne(optional = false)
+ private Series series;
+
+ @NonNull
+ @Column(nullable = false)
+ private ZonedDateTime date;
+
+ public DataId(@NonNull final Series series, @NonNull final ZonedDateTime date, @NonNull final Interval interval) {
+ this.series = series;
+ this.date = interval.align.apply(date);
+ }
+
+ public DataId(@NonNull final Series series, @NonNull final ZonedDateTime date) {
+ this.series = series;
+ this.date = date;
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/Interval.java b/src/main/java/de/ph87/data/series/data/Interval.java
new file mode 100644
index 0000000..2a7e623
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/Interval.java
@@ -0,0 +1,29 @@
+package de.ph87.data.series.data;
+
+import lombok.NonNull;
+
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.function.Function;
+
+public enum Interval {
+ FIVE(t -> t.truncatedTo(ChronoUnit.MINUTES).minusMinutes(t.getMinute() % 5), ChronoUnit.MINUTES, 5),
+ HOUR(t -> t.truncatedTo(ChronoUnit.HOURS), ChronoUnit.HOURS, 1),
+ DAY(t -> t.truncatedTo(ChronoUnit.DAYS), ChronoUnit.DAYS, 1),
+ WEEK(t -> t.truncatedTo(ChronoUnit.DAYS).minusDays(t.getDayOfWeek().getValue() - 1), ChronoUnit.WEEKS, 1),
+ MONTH(t -> t.truncatedTo(ChronoUnit.DAYS).minusDays(t.getDayOfMonth() - 1), ChronoUnit.MONTHS, 1),
+ YEAR(t -> t.truncatedTo(ChronoUnit.DAYS).minusDays(t.getDayOfYear() - 1), ChronoUnit.YEARS, 1),
+ ;
+
+ public final Function align;
+
+ public final ChronoUnit unit;
+
+ public final int amount;
+
+ Interval(final Function align, @NonNull final ChronoUnit unit, final int amount) {
+ this.align = align;
+ this.unit = unit;
+ this.amount = amount;
+ }
+}
diff --git a/src/main/java/de/ph87/data/series/data/bool/Bool.java b/src/main/java/de/ph87/data/series/data/bool/Bool.java
new file mode 100644
index 0000000..484ae97
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/bool/Bool.java
@@ -0,0 +1,48 @@
+package de.ph87.data.series.data.bool;
+
+import de.ph87.data.series.data.DataId;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Version;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.NonNull;
+import lombok.Setter;
+import lombok.ToString;
+
+import java.time.ZonedDateTime;
+
+@Entity
+@Getter
+@ToString
+@NoArgsConstructor
+public class Bool {
+
+ @Id
+ @NonNull
+ private DataId id;
+
+ @Version
+ private long version;
+
+ @Column(nullable = false)
+ private boolean state;
+
+ @Setter
+ @NonNull
+ @Column(nullable = false, name = "`end`")
+ private ZonedDateTime end;
+
+ @Setter
+ @Column(nullable = false)
+ private boolean terminated;
+
+ public Bool(@NonNull final DataId id, @NonNull final ZonedDateTime end, final boolean state, final boolean terminated) {
+ this.id = id;
+ this.end = end;
+ this.state = state;
+ this.terminated = terminated;
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/bool/BoolDto.java b/src/main/java/de/ph87/data/series/data/bool/BoolDto.java
new file mode 100644
index 0000000..7a39b08
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/bool/BoolDto.java
@@ -0,0 +1,34 @@
+package de.ph87.data.series.data.bool;
+
+import de.ph87.data.series.SeriesDto;
+import de.ph87.data.websocket.IWebsocketMessage;
+import lombok.Data;
+import lombok.NonNull;
+
+import java.time.ZonedDateTime;
+
+@Data
+public class BoolDto implements IWebsocketMessage {
+
+ @NonNull
+ public final SeriesDto series;
+
+ @NonNull
+ public final ZonedDateTime date;
+
+ public final boolean state;
+
+ @NonNull
+ public final ZonedDateTime end;
+
+ public final boolean terminated;
+
+ public BoolDto(@NonNull final Bool bool) {
+ this.series = new SeriesDto(bool.getId().getSeries());
+ this.date = bool.getId().getDate();
+ this.end = bool.getEnd();
+ this.state = bool.isState();
+ this.terminated = bool.isTerminated();
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/bool/BoolPoint.java b/src/main/java/de/ph87/data/series/data/bool/BoolPoint.java
new file mode 100644
index 0000000..96ae7c2
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/bool/BoolPoint.java
@@ -0,0 +1,36 @@
+package de.ph87.data.series.data.bool;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import de.ph87.data.series.SeriesPoint;
+import lombok.NonNull;
+
+import java.io.IOException;
+import java.time.ZonedDateTime;
+
+@SuppressWarnings("unused") // used by repository query
+public class BoolPoint implements SeriesPoint {
+
+ public final ZonedDateTime begin;
+
+ public final ZonedDateTime end;
+
+ public final boolean state;
+
+ public final boolean terminated;
+
+ public BoolPoint(@NonNull final Bool bool) {
+ this.begin = bool.getId().getDate();
+ this.end = bool.getEnd();
+ this.state = bool.isState();
+ this.terminated = bool.isTerminated();
+ }
+
+ @Override
+ public void toJson(final JsonGenerator jsonGenerator) throws IOException {
+ jsonGenerator.writeNumber(begin.toEpochSecond());
+ jsonGenerator.writeNumber(end.toEpochSecond());
+ jsonGenerator.writeNumber(state ? 1 : 0);
+ jsonGenerator.writeNumber(terminated ? 1 : 0);
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/bool/BoolRepo.java b/src/main/java/de/ph87/data/series/data/bool/BoolRepo.java
new file mode 100644
index 0000000..00bcf90
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/bool/BoolRepo.java
@@ -0,0 +1,18 @@
+package de.ph87.data.series.data.bool;
+
+import de.ph87.data.series.Series;
+import de.ph87.data.series.data.DataId;
+import lombok.NonNull;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.CrudRepository;
+
+import java.time.ZonedDateTime;
+import java.util.List;
+
+public interface BoolRepo extends CrudRepository {
+
+ @NonNull
+ @Query("select new de.ph87.data.series.data.bool.BoolPoint(e) from Bool e where e.id.series = :series and e.end >= :first and e.id.date < :after")
+ List points(@NonNull Series series, @NonNull ZonedDateTime first, @NonNull ZonedDateTime after);
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/bool/BoolService.java b/src/main/java/de/ph87/data/series/data/bool/BoolService.java
new file mode 100644
index 0000000..c88124e
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/bool/BoolService.java
@@ -0,0 +1,66 @@
+package de.ph87.data.series.data.bool;
+
+import de.ph87.data.series.Series;
+import de.ph87.data.series.SeriesPointsRequest;
+import de.ph87.data.series.data.DataId;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.ZonedDateTime;
+import java.util.List;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class BoolService {
+
+ private final BoolRepo boolRepo;
+
+ private final ApplicationEventPublisher applicationEventPublisher;
+
+ @Transactional
+ public void write(@NonNull final Series series, @NonNull final ZonedDateTime begin, @NonNull final ZonedDateTime end, final boolean state, final boolean terminated) {
+ final Bool bool = updateOrCreate(series, begin, end, state, terminated);
+ log.debug("Bool written: {}", bool);
+ applicationEventPublisher.publishEvent(new BoolDto(bool));
+ }
+
+ @NonNull
+ private Bool updateOrCreate(@NonNull final Series series, @NonNull final ZonedDateTime begin, @NonNull final ZonedDateTime end, final boolean state, final boolean terminated) {
+ final DataId id = new DataId(series, begin);
+ return boolRepo
+ .findById(id)
+ .stream()
+ .peek(
+ existing -> {
+ if (existing.isState() != state) {
+ id.getSeries().error(log, "Differing states: received=(begin=%s, end=%s, state=%s, terminated=%s), existing=%s".formatted(begin, end, state, terminated, existing));
+ return;
+ }
+ if (existing.getEnd().isAfter(end)) {
+ id.getSeries().error(log, "End ran backwards: received=(begin=%s, end=%s, state=%s, terminated=%s), existing=%s".formatted(begin, end, state, terminated, existing));
+ return;
+ }
+ if (existing.isTerminated() && (!terminated || !existing.getEnd().equals(end))) {
+ id.getSeries().error(log, "Already terminated: received=(begin=%s, end=%s, state=%s, terminated=%s), existing=%s".formatted(begin, end, state, terminated, existing));
+ return;
+ }
+ existing.setEnd(end);
+ existing.setTerminated(terminated);
+ })
+ .findFirst()
+ .orElseGet(
+ () -> boolRepo.save(new Bool(id, end, state, terminated))
+ );
+ }
+
+ @NonNull
+ public List points(@NonNull final Series series, @NonNull final SeriesPointsRequest request) {
+ return boolRepo.points(series, request.first, request.after);
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/delta/Delta.java b/src/main/java/de/ph87/data/series/data/delta/Delta.java
new file mode 100644
index 0000000..18a50a2
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/delta/Delta.java
@@ -0,0 +1,116 @@
+package de.ph87.data.series.data.delta;
+
+import de.ph87.data.series.data.DataId;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.MappedSuperclass;
+import jakarta.persistence.Version;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.NonNull;
+import lombok.ToString;
+
+@Getter
+@ToString
+@MappedSuperclass
+@NoArgsConstructor
+public abstract class Delta {
+
+ @Id
+ @NonNull
+ private DataId id;
+
+ @Version
+ private long version;
+
+ @Column(nullable = false)
+ private double first;
+
+ @NonNull
+ @Column(nullable = false)
+ private double last;
+
+ protected Delta(@NonNull final DataId id, final double value) {
+ this.id = id;
+ this.first = value;
+ this.last = value;
+ }
+
+ public void update(final double value) {
+ this.last = value;
+ }
+
+ @Getter
+ @ToString
+ @NoArgsConstructor
+ @Entity(name = "DeltaFive")
+ public static class Five extends Delta {
+
+ public Five(@NonNull final DataId id, @NonNull final double value) {
+ super(id, value);
+ }
+
+ }
+
+ @Getter
+ @ToString
+ @NoArgsConstructor
+ @Entity(name = "DeltaHour")
+ public static class Hour extends Delta {
+
+ public Hour(@NonNull final DataId id, @NonNull final double value) {
+ super(id, value);
+ }
+
+ }
+
+ @Getter
+ @ToString
+ @NoArgsConstructor
+ @Entity(name = "DeltaDay")
+ public static class Day extends Delta {
+
+ public Day(@NonNull final DataId id, @NonNull final double value) {
+ super(id, value);
+ }
+
+ }
+
+ @Getter
+ @ToString
+ @NoArgsConstructor
+ @Entity(name = "DeltaWeek")
+ public static class Week extends Delta {
+
+ public Week(@NonNull final DataId id, @NonNull final double value) {
+ super(id, value);
+ }
+
+ }
+
+ @Getter
+ @ToString
+ @NoArgsConstructor
+ @Entity(name = "DeltaMonth")
+ public static class Month extends Delta {
+
+ public Month(@NonNull final DataId id, @NonNull final double value) {
+ super(id, value);
+ }
+
+ }
+
+ @Getter
+ @ToString
+ @NoArgsConstructor
+ @Entity(name = "DeltaYear")
+ public static class Year extends Delta {
+
+ public Year(@NonNull final DataId id, @NonNull final double value) {
+ super(id, value);
+ }
+
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaDto.java b/src/main/java/de/ph87/data/series/data/delta/DeltaDto.java
new file mode 100644
index 0000000..0fa4706
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/delta/DeltaDto.java
@@ -0,0 +1,87 @@
+package de.ph87.data.series.data.delta;
+
+import de.ph87.data.series.data.DataId;
+import de.ph87.data.websocket.IWebsocketMessage;
+import lombok.Data;
+import lombok.Getter;
+import lombok.NonNull;
+import lombok.ToString;
+
+@Data
+public abstract class DeltaDto implements IWebsocketMessage {
+
+ @NonNull
+ public final DataId id;
+
+ public final double first;
+
+ @NonNull
+ public final double last;
+
+ protected DeltaDto(@NonNull final Delta delta) {
+ this.id = delta.getId();
+ this.first = delta.getFirst();
+ this.last = delta.getLast();
+ }
+
+ @Getter
+ @ToString
+ public static class Five extends DeltaDto {
+
+ public Five(@NonNull final Delta.Five delta) {
+ super(delta);
+ }
+
+ }
+
+ @Getter
+ @ToString
+ public static class Hour extends DeltaDto {
+
+ public Hour(@NonNull final Delta.Hour delta) {
+ super(delta);
+ }
+
+ }
+
+ @Getter
+ @ToString
+ public static class Day extends DeltaDto {
+
+ public Day(@NonNull final Delta.Day delta) {
+ super(delta);
+ }
+
+ }
+
+ @Getter
+ @ToString
+ public static class Week extends DeltaDto {
+
+ public Week(@NonNull final Delta.Week delta) {
+ super(delta);
+ }
+
+ }
+
+ @Getter
+ @ToString
+ public static class Month extends DeltaDto {
+
+ public Month(@NonNull final Delta.Month delta) {
+ super(delta);
+ }
+
+ }
+
+ @Getter
+ @ToString
+ public static class Year extends DeltaDto {
+
+ public Year(@NonNull final Delta.Year delta) {
+ super(delta);
+ }
+
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaPoint.java b/src/main/java/de/ph87/data/series/data/delta/DeltaPoint.java
new file mode 100644
index 0000000..9ee466f
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/delta/DeltaPoint.java
@@ -0,0 +1,32 @@
+package de.ph87.data.series.data.delta;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import de.ph87.data.series.SeriesPoint;
+import lombok.NonNull;
+
+import java.io.IOException;
+import java.time.ZonedDateTime;
+
+@SuppressWarnings("unused") // used by repository query
+public class DeltaPoint implements SeriesPoint {
+
+ public final ZonedDateTime date;
+
+ public final double first;
+
+ public final double last;
+
+ public DeltaPoint(@NonNull final Delta delta) {
+ this.date = delta.getId().getDate();
+ this.first = delta.getFirst();
+ this.last = delta.getLast();
+ }
+
+ @Override
+ public void toJson(final JsonGenerator jsonGenerator) throws IOException {
+ jsonGenerator.writeNumber(date.toEpochSecond());
+ jsonGenerator.writeNumber(first);
+ jsonGenerator.writeNumber(last);
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaRepo.java b/src/main/java/de/ph87/data/series/data/delta/DeltaRepo.java
new file mode 100644
index 0000000..3d7de5c
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/delta/DeltaRepo.java
@@ -0,0 +1,20 @@
+package de.ph87.data.series.data.delta;
+
+import de.ph87.data.series.Series;
+import de.ph87.data.series.data.DataId;
+import lombok.NonNull;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.data.repository.NoRepositoryBean;
+
+import java.time.ZonedDateTime;
+import java.util.List;
+
+@NoRepositoryBean
+public interface DeltaRepo extends CrudRepository {
+
+ @NonNull
+ @Query("select new de.ph87.data.series.data.delta.DeltaPoint(e) from #{#entityName} e where e.id.series = :series and e.id.date >= :first and e.id.date < :after")
+ List points(@NonNull Series series, @NonNull ZonedDateTime first, @NonNull ZonedDateTime after);
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaRepoDay.java b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoDay.java
new file mode 100644
index 0000000..c526efc
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoDay.java
@@ -0,0 +1,5 @@
+package de.ph87.data.series.data.delta;
+
+public interface DeltaRepoDay extends DeltaRepo {
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaRepoFive.java b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoFive.java
new file mode 100644
index 0000000..4f99a1a
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoFive.java
@@ -0,0 +1,5 @@
+package de.ph87.data.series.data.delta;
+
+public interface DeltaRepoFive extends DeltaRepo {
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaRepoHour.java b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoHour.java
new file mode 100644
index 0000000..8dc4b2a
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoHour.java
@@ -0,0 +1,5 @@
+package de.ph87.data.series.data.delta;
+
+public interface DeltaRepoHour extends DeltaRepo {
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaRepoMonth.java b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoMonth.java
new file mode 100644
index 0000000..fa12a47
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoMonth.java
@@ -0,0 +1,5 @@
+package de.ph87.data.series.data.delta;
+
+public interface DeltaRepoMonth extends DeltaRepo {
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaRepoWeek.java b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoWeek.java
new file mode 100644
index 0000000..b7d0812
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoWeek.java
@@ -0,0 +1,5 @@
+package de.ph87.data.series.data.delta;
+
+public interface DeltaRepoWeek extends DeltaRepo {
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaRepoYear.java b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoYear.java
new file mode 100644
index 0000000..179716f
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/delta/DeltaRepoYear.java
@@ -0,0 +1,5 @@
+package de.ph87.data.series.data.delta;
+
+public interface DeltaRepoYear extends DeltaRepo {
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/delta/DeltaService.java b/src/main/java/de/ph87/data/series/data/delta/DeltaService.java
new file mode 100644
index 0000000..2b7066d
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/delta/DeltaService.java
@@ -0,0 +1,67 @@
+package de.ph87.data.series.data.delta;
+
+import de.ph87.data.series.Series;
+import de.ph87.data.series.SeriesPointsRequest;
+import de.ph87.data.series.data.DataId;
+import de.ph87.data.series.data.Interval;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.ZonedDateTime;
+import java.util.List;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class DeltaService {
+
+ private final DeltaRepoFive five;
+
+ private final DeltaRepoHour hour;
+
+ private final DeltaRepoDay day;
+
+ private final DeltaRepoWeek week;
+
+ private final DeltaRepoMonth month;
+
+ private final DeltaRepoYear year;
+
+ private final ApplicationEventPublisher applicationEventPublisher;
+
+ @Transactional
+ public void write(@NonNull final Series series, @NonNull final ZonedDateTime date, final double value) {
+ write(series, five, Interval.FIVE, date, value, Delta.Five::new, DeltaDto.Five::new);
+ write(series, hour, Interval.HOUR, date, value, Delta.Hour::new, DeltaDto.Hour::new);
+ write(series, day, Interval.DAY, date, value, Delta.Day::new, DeltaDto.Day::new);
+ write(series, week, Interval.WEEK, date, value, Delta.Week::new, DeltaDto.Week::new);
+ write(series, month, Interval.MONTH, date, value, Delta.Month::new, DeltaDto.Month::new);
+ write(series, year, Interval.YEAR, date, value, Delta.Year::new, DeltaDto.Year::new);
+ }
+
+ private void write(@NonNull final Series series, @NonNull final DeltaRepo repo, @NonNull final Interval interval, @NonNull final ZonedDateTime date, final double value, @NonNull final BiFunction create, @NonNull final Function toDto) {
+ final DataId id = new DataId(series, date, interval);
+ final DELTA delta = repo.findById(id).stream().peek(existing -> existing.update(value)).findFirst().orElseGet(() -> repo.save(create.apply(id, value)));
+ log.debug("Delta written: {}", delta);
+ applicationEventPublisher.publishEvent(toDto.apply(delta));
+ }
+
+ @NonNull
+ public List points(@NonNull final Series series, @NonNull final SeriesPointsRequest request) {
+ return switch (request.interval) {
+ case FIVE -> five.points(series, request.first, request.after);
+ case HOUR -> hour.points(series, request.first, request.after);
+ case DAY -> day.points(series, request.first, request.after);
+ case WEEK -> week.points(series, request.first, request.after);
+ case MONTH -> month.points(series, request.first, request.after);
+ case YEAR -> year.points(series, request.first, request.after);
+ };
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/varying/Varying.java b/src/main/java/de/ph87/data/series/data/varying/Varying.java
new file mode 100644
index 0000000..8f1c16e
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/varying/Varying.java
@@ -0,0 +1,126 @@
+package de.ph87.data.series.data.varying;
+
+import de.ph87.data.series.data.DataId;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.MappedSuperclass;
+import jakarta.persistence.Version;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.NonNull;
+import lombok.ToString;
+
+@Getter
+@ToString
+@MappedSuperclass
+@NoArgsConstructor
+public abstract class Varying {
+
+ @Id
+ @NonNull
+ private DataId id;
+
+ @Version
+ private long version;
+
+ @Column(nullable = false)
+ private double min;
+
+ @Column(nullable = false)
+ private double max;
+
+ @Column(nullable = false)
+ private double avg;
+
+ @Column(nullable = false)
+ private int count;
+
+ protected Varying(@NonNull final DataId id, final double value) {
+ this.id = id;
+ this.min = value;
+ this.max = value;
+ this.avg = value;
+ this.count = 1;
+ }
+
+ public void update(final double value) {
+ this.min = Math.min(this.min, value);
+ this.max = Math.max(this.max, value);
+ this.avg = (this.avg * this.count + value) / (this.count + 1);
+ this.count++;
+ }
+
+ @Getter
+ @ToString
+ @NoArgsConstructor
+ @Entity(name = "VaryingFive")
+ public static class Five extends Varying {
+
+ public Five(@NonNull final DataId id, @NonNull final double value) {
+ super(id, value);
+ }
+
+ }
+
+ @Getter
+ @ToString
+ @NoArgsConstructor
+ @Entity(name = "VaryingHour")
+ public static class Hour extends Varying {
+
+ public Hour(@NonNull final DataId id, @NonNull final double value) {
+ super(id, value);
+ }
+
+ }
+
+ @Getter
+ @ToString
+ @NoArgsConstructor
+ @Entity(name = "VaryingDay")
+ public static class Day extends Varying {
+
+ public Day(@NonNull final DataId id, @NonNull final double value) {
+ super(id, value);
+ }
+
+ }
+
+ @Getter
+ @ToString
+ @NoArgsConstructor
+ @Entity(name = "VaryingWeek")
+ public static class Week extends Varying {
+
+ public Week(@NonNull final DataId id, @NonNull final double value) {
+ super(id, value);
+ }
+
+ }
+
+ @Getter
+ @ToString
+ @NoArgsConstructor
+ @Entity(name = "VaryingMonth")
+ public static class Month extends Varying {
+
+ public Month(@NonNull final DataId id, @NonNull final double value) {
+ super(id, value);
+ }
+
+ }
+
+ @Getter
+ @ToString
+ @NoArgsConstructor
+ @Entity(name = "VaryingYear")
+ public static class Year extends Varying {
+
+ public Year(@NonNull final DataId id, @NonNull final double value) {
+ super(id, value);
+ }
+
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingDto.java b/src/main/java/de/ph87/data/series/data/varying/VaryingDto.java
new file mode 100644
index 0000000..0e31545
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/varying/VaryingDto.java
@@ -0,0 +1,98 @@
+package de.ph87.data.series.data.varying;
+
+import de.ph87.data.series.SeriesDto;
+import de.ph87.data.websocket.IWebsocketMessage;
+import lombok.Data;
+import lombok.Getter;
+import lombok.NonNull;
+import lombok.ToString;
+
+import java.time.ZonedDateTime;
+
+@Data
+public abstract class VaryingDto implements IWebsocketMessage {
+
+ @NonNull
+ public final SeriesDto seriesDto;
+
+ @NonNull
+ public final ZonedDateTime date;
+
+ public final double min;
+
+ public final double max;
+
+ public final double avg;
+
+ public final int count;
+
+ protected VaryingDto(@NonNull final Varying varying) {
+ this.seriesDto = new SeriesDto(varying.getId().getSeries());
+ this.date = varying.getId().getDate();
+ this.min = varying.getMin();
+ this.max = varying.getMax();
+ this.avg = varying.getAvg();
+ this.count = varying.getCount();
+ }
+
+ @Getter
+ @ToString
+ public static class Five extends VaryingDto {
+
+ public Five(@NonNull final Varying.Five varying) {
+ super(varying);
+ }
+
+ }
+
+ @Getter
+ @ToString
+ public static class Hour extends VaryingDto {
+
+ public Hour(@NonNull final Varying.Hour varying) {
+ super(varying);
+ }
+
+ }
+
+ @Getter
+ @ToString
+ public static class Day extends VaryingDto {
+
+ public Day(@NonNull final Varying.Day varying) {
+ super(varying);
+ }
+
+ }
+
+ @Getter
+ @ToString
+ public static class Week extends VaryingDto {
+
+ public Week(@NonNull final Varying.Week varying) {
+ super(varying);
+ }
+
+ }
+
+ @Getter
+ @ToString
+ public static class Month extends VaryingDto {
+
+ public Month(@NonNull final Varying.Month varying) {
+ super(varying);
+ }
+
+ }
+
+ @Getter
+ @ToString
+ public static class Year extends VaryingDto {
+
+ public Year(@NonNull final Varying.Year varying) {
+ super(varying);
+ }
+
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingPoint.java b/src/main/java/de/ph87/data/series/data/varying/VaryingPoint.java
new file mode 100644
index 0000000..37fe3d5
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/varying/VaryingPoint.java
@@ -0,0 +1,36 @@
+package de.ph87.data.series.data.varying;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import de.ph87.data.series.SeriesPoint;
+import lombok.NonNull;
+
+import java.io.IOException;
+import java.time.ZonedDateTime;
+
+@SuppressWarnings("unused") // used by repository query
+public class VaryingPoint implements SeriesPoint {
+
+ public final ZonedDateTime date;
+
+ public final double min;
+
+ public final double max;
+
+ public final double avg;
+
+ public VaryingPoint(@NonNull final Varying varying) {
+ this.date = varying.getId().getDate();
+ this.min = varying.getMin();
+ this.max = varying.getMax();
+ this.avg = varying.getAvg();
+ }
+
+ @Override
+ public void toJson(final JsonGenerator jsonGenerator) throws IOException {
+ jsonGenerator.writeNumber(date.toEpochSecond());
+ jsonGenerator.writeNumber(min);
+ jsonGenerator.writeNumber(max);
+ jsonGenerator.writeNumber(avg);
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingRepo.java b/src/main/java/de/ph87/data/series/data/varying/VaryingRepo.java
new file mode 100644
index 0000000..fa8a70b
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/varying/VaryingRepo.java
@@ -0,0 +1,20 @@
+package de.ph87.data.series.data.varying;
+
+import de.ph87.data.series.Series;
+import de.ph87.data.series.data.DataId;
+import lombok.NonNull;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.data.repository.NoRepositoryBean;
+
+import java.time.ZonedDateTime;
+import java.util.List;
+
+@NoRepositoryBean
+public interface VaryingRepo extends CrudRepository {
+
+ @NonNull
+ @Query("select new de.ph87.data.series.data.varying.VaryingPoint(e) from #{#entityName} e where e.id.series = :series and e.id.date >= :first and e.id.date < :after")
+ List points(@NonNull Series series, @NonNull ZonedDateTime first, @NonNull ZonedDateTime after);
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingRepoDay.java b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoDay.java
new file mode 100644
index 0000000..0ba2c73
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoDay.java
@@ -0,0 +1,5 @@
+package de.ph87.data.series.data.varying;
+
+public interface VaryingRepoDay extends VaryingRepo {
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingRepoFive.java b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoFive.java
new file mode 100644
index 0000000..57ca719
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoFive.java
@@ -0,0 +1,5 @@
+package de.ph87.data.series.data.varying;
+
+public interface VaryingRepoFive extends VaryingRepo {
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingRepoHour.java b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoHour.java
new file mode 100644
index 0000000..8769b05
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoHour.java
@@ -0,0 +1,5 @@
+package de.ph87.data.series.data.varying;
+
+public interface VaryingRepoHour extends VaryingRepo {
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingRepoMonth.java b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoMonth.java
new file mode 100644
index 0000000..d57f4c4
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoMonth.java
@@ -0,0 +1,5 @@
+package de.ph87.data.series.data.varying;
+
+public interface VaryingRepoMonth extends VaryingRepo {
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingRepoWeek.java b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoWeek.java
new file mode 100644
index 0000000..47091ad
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoWeek.java
@@ -0,0 +1,5 @@
+package de.ph87.data.series.data.varying;
+
+public interface VaryingRepoWeek extends VaryingRepo {
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingRepoYear.java b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoYear.java
new file mode 100644
index 0000000..fefbef8
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/varying/VaryingRepoYear.java
@@ -0,0 +1,5 @@
+package de.ph87.data.series.data.varying;
+
+public interface VaryingRepoYear extends VaryingRepo {
+
+}
diff --git a/src/main/java/de/ph87/data/series/data/varying/VaryingService.java b/src/main/java/de/ph87/data/series/data/varying/VaryingService.java
new file mode 100644
index 0000000..49164bf
--- /dev/null
+++ b/src/main/java/de/ph87/data/series/data/varying/VaryingService.java
@@ -0,0 +1,67 @@
+package de.ph87.data.series.data.varying;
+
+import de.ph87.data.series.Series;
+import de.ph87.data.series.SeriesPointsRequest;
+import de.ph87.data.series.data.DataId;
+import de.ph87.data.series.data.Interval;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.ZonedDateTime;
+import java.util.List;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class VaryingService {
+
+ private final VaryingRepoFive five;
+
+ private final VaryingRepoHour hour;
+
+ private final VaryingRepoDay day;
+
+ private final VaryingRepoWeek week;
+
+ private final VaryingRepoMonth month;
+
+ private final VaryingRepoYear year;
+
+ private final ApplicationEventPublisher applicationEventPublisher;
+
+ @Transactional
+ public void write(@NonNull final Series series, @NonNull final ZonedDateTime date, final double value) {
+ write(series, five, Interval.FIVE, date, value, Varying.Five::new, VaryingDto.Five::new);
+ write(series, hour, Interval.HOUR, date, value, Varying.Hour::new, VaryingDto.Hour::new);
+ write(series, day, Interval.DAY, date, value, Varying.Day::new, VaryingDto.Day::new);
+ write(series, week, Interval.WEEK, date, value, Varying.Week::new, VaryingDto.Week::new);
+ write(series, month, Interval.MONTH, date, value, Varying.Month::new, VaryingDto.Month::new);
+ write(series, year, Interval.YEAR, date, value, Varying.Year::new, VaryingDto.Year::new);
+ }
+
+ private void write(@NonNull final Series series, @NonNull final VaryingRepo repo, @NonNull final Interval interval, @NonNull final ZonedDateTime date, final double value, @NonNull final BiFunction create, @NonNull final Function toDto) {
+ final DataId id = new DataId(series, date, interval);
+ final VARYING varying = repo.findById(id).stream().peek(existing -> existing.update(value)).findFirst().orElseGet(() -> repo.save(create.apply(id, value)));
+ log.debug("Varying written: {}", varying);
+ applicationEventPublisher.publishEvent(toDto.apply(varying));
+ }
+
+ @NonNull
+ public List points(@NonNull final Series series, @NonNull final SeriesPointsRequest request) {
+ return switch (request.interval) {
+ case FIVE -> five.points(series, request.first, request.after);
+ case HOUR -> hour.points(series, request.first, request.after);
+ case DAY -> day.points(series, request.first, request.after);
+ case WEEK -> week.points(series, request.first, request.after);
+ case MONTH -> month.points(series, request.first, request.after);
+ case YEAR -> year.points(series, request.first, request.after);
+ };
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/topic/TimestampType.java b/src/main/java/de/ph87/data/topic/TimestampType.java
new file mode 100644
index 0000000..bc74417
--- /dev/null
+++ b/src/main/java/de/ph87/data/topic/TimestampType.java
@@ -0,0 +1,5 @@
+package de.ph87.data.topic;
+
+public enum TimestampType {
+ EPOCH_MILLISECONDS, EPOCH_SECONDS
+}
diff --git a/src/main/java/de/ph87/data/topic/Topic.java b/src/main/java/de/ph87/data/topic/Topic.java
new file mode 100644
index 0000000..21efd48
--- /dev/null
+++ b/src/main/java/de/ph87/data/topic/Topic.java
@@ -0,0 +1,85 @@
+package de.ph87.data.topic;
+
+import de.ph87.data.log.AbstractEntityLog;
+import de.ph87.data.topic.query.TopicQuery;
+import jakarta.persistence.Column;
+import jakarta.persistence.ElementCollection;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Version;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.NonNull;
+import lombok.Setter;
+import lombok.ToString;
+
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+@Getter
+@ToString
+@NoArgsConstructor
+public class Topic extends AbstractEntityLog {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private long id;
+
+ @Version
+ private long version;
+
+ @NonNull
+ @Column(nullable = false, unique = true)
+ private String name;
+
+ @Setter
+ @Column(nullable = false)
+ private boolean enabled = true;
+
+ @NonNull
+ @Column(nullable = false)
+ private ZonedDateTime first;
+
+ @NonNull
+ @Column(nullable = false)
+ private ZonedDateTime last;
+
+ @Column(nullable = false)
+ private int count;
+
+ @Setter
+ @NonNull
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false)
+ private TimestampType timestampType = TimestampType.EPOCH_SECONDS;
+
+ @Setter
+ @NonNull
+ @Column(nullable = false)
+ private String timestampQuery = "";
+
+ @NonNull
+ @ToString.Exclude
+ @ElementCollection(fetch = FetchType.EAGER)
+ private List queries = new ArrayList<>();
+
+ public Topic(@NonNull final String name) {
+ this.name = name;
+ this.first = ZonedDateTime.now();
+ this.last = this.first;
+ this.count = 1;
+ }
+
+ public void update() {
+ this.last = ZonedDateTime.now();
+ this.count++;
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/topic/TopicController.java b/src/main/java/de/ph87/data/topic/TopicController.java
new file mode 100644
index 0000000..2fa19d7
--- /dev/null
+++ b/src/main/java/de/ph87/data/topic/TopicController.java
@@ -0,0 +1,45 @@
+package de.ph87.data.topic;
+
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.CrossOrigin;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@CrossOrigin
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("Topic")
+public class TopicController {
+
+ private final TopicService topicService;
+
+ private final TopicRepository topicRepository;
+
+ @GetMapping("findAll")
+ public List findAll() {
+ return topicRepository.findAllDto();
+ }
+
+ @PostMapping("{id}/setEnabled")
+ public TopicDto setEnabled(@PathVariable final long id, @RequestBody final boolean enabled) {
+ return topicService.setEnabled(id, enabled);
+ }
+
+ @PostMapping("{id}/setTimestampQuery")
+ public TopicDto setTimestampQuery(@PathVariable final long id, @NonNull @RequestBody final String timestampQuery) {
+ return topicService.setTimestampQuery(id, timestampQuery);
+ }
+
+ @PostMapping("{id}/setTimestampType")
+ public TopicDto setTimestampType(@PathVariable final long id, @NonNull @RequestBody final String timestampType) {
+ return topicService.setTimestampType(id, TimestampType.valueOf(timestampType));
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/topic/TopicDto.java b/src/main/java/de/ph87/data/topic/TopicDto.java
new file mode 100644
index 0000000..0e45cdd
--- /dev/null
+++ b/src/main/java/de/ph87/data/topic/TopicDto.java
@@ -0,0 +1,50 @@
+package de.ph87.data.topic;
+
+import de.ph87.data.topic.query.TopicQueryDto;
+import de.ph87.data.websocket.IWebsocketMessage;
+import lombok.Data;
+import lombok.NonNull;
+
+import java.time.ZonedDateTime;
+import java.util.List;
+
+@Data
+public class TopicDto implements IWebsocketMessage {
+
+ public final long id;
+
+ @NonNull
+ public final String name;
+
+ @NonNull
+ public final ZonedDateTime first;
+
+ @NonNull
+ public final ZonedDateTime last;
+
+ public final long count;
+
+ public final boolean enabled;
+
+ @NonNull
+ public final TimestampType timestampType;
+
+ @NonNull
+ public final String timestampQuery;
+
+ @NonNull
+ public final List queries;
+
+ public TopicDto(@NonNull final Topic topic) {
+ this.id = topic.getId();
+ this.name = topic.getName();
+ this.first = topic.getFirst();
+ this.last = topic.getLast();
+ this.count = topic.getCount();
+ this.enabled = topic.isEnabled();
+ this.timestampType = topic.getTimestampType();
+ this.timestampQuery = topic.getTimestampQuery();
+ this.queries = topic.getQueries().stream().map(TopicQueryDto::new).toList();
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/topic/TopicReceiver.java b/src/main/java/de/ph87/data/topic/TopicReceiver.java
new file mode 100644
index 0000000..2921180
--- /dev/null
+++ b/src/main/java/de/ph87/data/topic/TopicReceiver.java
@@ -0,0 +1,129 @@
+package de.ph87.data.topic;
+
+import com.jayway.jsonpath.DocumentContext;
+import com.jayway.jsonpath.JsonPath;
+import de.ph87.data.mqtt.MqttInbound;
+import de.ph87.data.series.Series;
+import de.ph87.data.series.SeriesDto;
+import de.ph87.data.series.SeriesType;
+import de.ph87.data.series.data.bool.BoolService;
+import de.ph87.data.series.data.delta.DeltaService;
+import de.ph87.data.series.data.varying.VaryingService;
+import de.ph87.data.topic.query.TopicQuery;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class TopicReceiver {
+
+ private final TopicRepository topicRepository;
+
+ private final BoolService boolService;
+
+ private final DeltaService deltaService;
+
+ private final VaryingService varyingService;
+
+ private final ApplicationEventPublisher applicationEventPublisher;
+
+ @Transactional
+ public void receive(@NonNull final MqttInbound inbound) {
+ final Topic topic = updateOrCreate(inbound.topic);
+ if (!topic.isEnabled()) {
+ log.debug("Topic is not enabled: topic={}", topic);
+ return;
+ }
+ if (topic.getTimestampQuery().isEmpty()) {
+ log.debug("Topic timestampQuery is not set: topic={}", topic);
+ return;
+ }
+ if (topic.getQueries().isEmpty()) {
+ log.debug("Topic queries not set: topic={}", topic);
+ return;
+ }
+
+ log.debug("Parsing Topic payload: topic={}", topic);
+ final DocumentContext json;
+ try {
+ json = JsonPath.parse(inbound.payload);
+ } catch (Exception e) {
+ topic.error(log, "Error parsing JSON: %s\n topic=%s\n inbound=%s".formatted(e.toString(), topic, inbound), e);
+ return;
+ }
+
+ log.debug("Executing Topic timestampQuery: topic={}", topic);
+ final ZonedDateTime date;
+ try {
+ date = queryTimestamp(json, topic.getTimestampQuery(), topic.getTimestampType());
+ } catch (Exception e) {
+ topic.error(log, "Error executing Topic timestampQuery: %s\n topic=%s\n inbound=%s".formatted(e.toString(), topic, inbound), e);
+ return;
+ }
+
+ topic.getQueries().forEach(query -> query(topic, inbound, json, date, query));
+ }
+
+ private void query(@NonNull final Topic topic, @NonNull final MqttInbound inbound, @NonNull final DocumentContext json, @NonNull final ZonedDateTime date, @NonNull final TopicQuery query) {
+ log.debug("Executing TopicQuery: topicQuery={}", query);
+ try {
+ final Series series = query.getSeries();
+ if (series == null) {
+ log.debug("TopicQuery Series not set: topic={}", topic);
+ return;
+ }
+ if (query.getValueQuery().isEmpty()) {
+ log.debug("TopicQuery valueQuery not set: topic={}", topic);
+ return;
+ }
+ if (series.getType() == SeriesType.BOOL) {
+ if (query.getBeginQuery().isEmpty()) {
+ log.debug("TopicQuery beginQuery not set: topic={}", topic);
+ return;
+ }
+ if (query.getTerminatedQuery().isEmpty()) {
+ log.debug("TopicQuery terminatedQuery not set: topic={}", topic);
+ return;
+ }
+ }
+ final double value = query.getFunction().apply(json.read(query.getValueQuery(), Double.class)) * query.getFactor();
+ series.update(date, value);
+ applicationEventPublisher.publishEvent(new SeriesDto(series));
+
+ switch (series.getType()) {
+ case BOOL -> {
+ final ZonedDateTime begin = queryTimestamp(json, query.getBeginQuery(), topic.getTimestampType());
+ final boolean terminated = json.read(query.getTerminatedQuery(), Boolean.class);
+ boolService.write(series, begin, date, value > 0, terminated);
+ }
+ case DELTA -> deltaService.write(series, date, value);
+ case VARYING -> varyingService.write(series, date, value);
+ }
+ } catch (Exception e) {
+ topic.error(log, "Error executing TopicQuery: %s\n topic=%s\n query=%s\n inbound=%s".formatted(e.toString(), topic, query, inbound), e);
+ }
+ }
+
+ @NonNull
+ private Topic updateOrCreate(@NonNull final String name) {
+ return topicRepository.findByName(name).stream().peek(Topic::update).findFirst().orElseGet(() -> topicRepository.save(new Topic(name)));
+ }
+
+ @NonNull
+ private static ZonedDateTime queryTimestamp(@NonNull final DocumentContext json, @NonNull final String query, @NonNull final TimestampType type) {
+ return switch (type) {
+ case TimestampType.EPOCH_SECONDS -> ZonedDateTime.ofInstant(Instant.ofEpochSecond(json.read(query, Long.class)), ZoneId.systemDefault());
+ case TimestampType.EPOCH_MILLISECONDS -> ZonedDateTime.ofInstant(Instant.ofEpochMilli(json.read(query, Long.class)), ZoneId.systemDefault());
+ };
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/topic/TopicRepository.java b/src/main/java/de/ph87/data/topic/TopicRepository.java
new file mode 100644
index 0000000..c325c77
--- /dev/null
+++ b/src/main/java/de/ph87/data/topic/TopicRepository.java
@@ -0,0 +1,18 @@
+package de.ph87.data.topic;
+
+import lombok.NonNull;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.ListCrudRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface TopicRepository extends ListCrudRepository {
+
+ @NonNull
+ Optional findByName(@NonNull String name);
+
+ @Query("select new de.ph87.data.topic.TopicDto(t) from Topic t")
+ List findAllDto();
+
+}
diff --git a/src/main/java/de/ph87/data/topic/TopicService.java b/src/main/java/de/ph87/data/topic/TopicService.java
new file mode 100644
index 0000000..ba2e4ee
--- /dev/null
+++ b/src/main/java/de/ph87/data/topic/TopicService.java
@@ -0,0 +1,46 @@
+package de.ph87.data.topic;
+
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.function.Consumer;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class TopicService {
+
+ private final TopicRepository topicRepository;
+
+ private final ApplicationEventPublisher applicationEventPublisher;
+
+ @Transactional
+ public TopicDto setEnabled(final long id, final boolean enabled) {
+ return set(id, t -> t.setEnabled(enabled));
+ }
+
+ @Transactional
+ public TopicDto setTimestampQuery(final long id, @NonNull final String timestampQuery) {
+ return set(id, t -> t.setTimestampQuery(timestampQuery));
+ }
+
+ @Transactional
+ public TopicDto setTimestampType(final long id, @NonNull final TimestampType timestampType) {
+ return set(id, t -> t.setTimestampType(timestampType));
+ }
+
+ @NonNull
+ private TopicDto set(final long id, @NonNull final Consumer modifier) {
+ final Topic topic = topicRepository.findById(id).orElseThrow();
+ modifier.accept(topic);
+ log.info("Topic CHANGED: {}", topic);
+ final TopicDto dto = new TopicDto(topic);
+ applicationEventPublisher.publishEvent(dto);
+ return dto;
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/topic/query/TopicQuery.java b/src/main/java/de/ph87/data/topic/query/TopicQuery.java
new file mode 100644
index 0000000..ab97d7a
--- /dev/null
+++ b/src/main/java/de/ph87/data/topic/query/TopicQuery.java
@@ -0,0 +1,66 @@
+package de.ph87.data.topic.query;
+
+import de.ph87.data.series.Series;
+import jakarta.annotation.Nullable;
+import jakarta.persistence.Column;
+import jakarta.persistence.Embeddable;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.ManyToOne;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.NonNull;
+import lombok.ToString;
+
+@Getter
+@ToString
+@Embeddable
+@NoArgsConstructor
+public class TopicQuery {
+
+ @Nullable
+ @ManyToOne
+ private Series series;
+
+ @NonNull
+ @Column(nullable = false)
+ private String valueQuery = "";
+
+ @NonNull
+ @Column(nullable = false)
+ private String beginQuery = "";
+
+ @NonNull
+ @Column(nullable = false)
+ private String terminatedQuery = "";
+
+ @NonNull
+ @Column(nullable = false)
+ @Enumerated(EnumType.STRING)
+ private TopicQueryFunction function = TopicQueryFunction.NONE;
+
+ @Column(nullable = false)
+ private double factor;
+
+ public TopicQuery(@Nullable final Series series, @NonNull final String valueQuery) {
+ this(series, valueQuery, "", "", TopicQueryFunction.NONE, 1);
+ }
+
+ public TopicQuery(@Nullable final Series series, @NonNull final String valueQuery, final double factor) {
+ this(series, valueQuery, "", "", TopicQueryFunction.NONE, factor);
+ }
+
+ public TopicQuery(@Nullable final Series series, @NonNull final String valueQuery, @NonNull final TopicQueryFunction function) {
+ this(series, valueQuery, "", "", function, 1);
+ }
+
+ public TopicQuery(@Nullable final Series series, @NonNull final String valueQuery, @NonNull final String beginQuery, @NonNull final String terminatedQuery, @NonNull final TopicQueryFunction function, final double factor) {
+ this.series = series;
+ this.valueQuery = valueQuery;
+ this.beginQuery = beginQuery;
+ this.terminatedQuery = terminatedQuery;
+ this.function = function;
+ this.factor = factor;
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/topic/query/TopicQueryDto.java b/src/main/java/de/ph87/data/topic/query/TopicQueryDto.java
new file mode 100644
index 0000000..e18a029
--- /dev/null
+++ b/src/main/java/de/ph87/data/topic/query/TopicQueryDto.java
@@ -0,0 +1,41 @@
+package de.ph87.data.topic.query;
+
+import de.ph87.data.series.SeriesDto;
+import jakarta.annotation.Nullable;
+import lombok.Getter;
+import lombok.NonNull;
+import lombok.ToString;
+
+import static de.ph87.data.Helpers.map;
+
+@Getter
+@ToString
+public class TopicQueryDto {
+
+ @Nullable
+ public final SeriesDto series;
+
+ @NonNull
+ public final String valueQuery;
+
+ @NonNull
+ public final String beginQuery;
+
+ @NonNull
+ public final String terminatedQuery;
+
+ @NonNull
+ public final TopicQueryFunction function;
+
+ public final double factor;
+
+ public TopicQueryDto(@NonNull final TopicQuery topicQuery) {
+ this.series = map(topicQuery.getSeries(), SeriesDto::new);
+ this.valueQuery = topicQuery.getValueQuery();
+ this.beginQuery = topicQuery.getBeginQuery();
+ this.terminatedQuery = topicQuery.getTerminatedQuery();
+ this.function = topicQuery.getFunction();
+ this.factor = topicQuery.getFactor();
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/topic/query/TopicQueryFunction.java b/src/main/java/de/ph87/data/topic/query/TopicQueryFunction.java
new file mode 100644
index 0000000..3704395
--- /dev/null
+++ b/src/main/java/de/ph87/data/topic/query/TopicQueryFunction.java
@@ -0,0 +1,23 @@
+package de.ph87.data.topic.query;
+
+import lombok.NonNull;
+
+import java.util.function.Function;
+
+public enum TopicQueryFunction {
+ NONE(v -> v),
+ ONLY_POSITIVE(v -> v > 0 ? v : 0),
+ ONLY_NEGATIVE_BUT_NEGATE(v -> v < 0 ? -v : 0),
+ ;
+
+ private final Function function;
+
+ TopicQueryFunction(@NonNull Function function) {
+ this.function = function;
+ }
+
+ public double apply(final double value) {
+ return function.apply(value);
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/user/ConsumerWithException.java b/src/main/java/de/ph87/data/user/ConsumerWithException.java
deleted file mode 100644
index eefb0d4..0000000
--- a/src/main/java/de/ph87/data/user/ConsumerWithException.java
+++ /dev/null
@@ -1,8 +0,0 @@
-package de.ph87.data.user;
-
-@FunctionalInterface
-public interface ConsumerWithException {
-
- void accept(final T t) throws E;
-
-}
diff --git a/src/main/java/de/ph87/data/user/Principal.java b/src/main/java/de/ph87/data/user/Principal.java
deleted file mode 100644
index 5755c77..0000000
--- a/src/main/java/de/ph87/data/user/Principal.java
+++ /dev/null
@@ -1,23 +0,0 @@
-package de.ph87.data.user;
-
-import lombok.Data;
-import lombok.NonNull;
-
-@Data
-public class Principal {
-
- public final long id;
-
- @NonNull
- public final String token;
-
- @NonNull
- public final UserDto user;
-
- public Principal(@NonNull final String token, @NonNull final User user) {
- this.id = user.getId();
- this.token = token;
- this.user = new UserDto(user);
- }
-
-}
diff --git a/src/main/java/de/ph87/data/user/PrincipalArgumentResolver.java b/src/main/java/de/ph87/data/user/PrincipalArgumentResolver.java
deleted file mode 100644
index 176fbb1..0000000
--- a/src/main/java/de/ph87/data/user/PrincipalArgumentResolver.java
+++ /dev/null
@@ -1,65 +0,0 @@
-package de.ph87.data.user;
-
-import jakarta.servlet.http.Cookie;
-import jakarta.servlet.http.HttpServletRequest;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.core.MethodParameter;
-import org.springframework.http.HttpStatus;
-import org.springframework.stereotype.Component;
-import org.springframework.web.bind.support.WebDataBinderFactory;
-import org.springframework.web.context.request.NativeWebRequest;
-import org.springframework.web.method.support.HandlerMethodArgumentResolver;
-import org.springframework.web.method.support.ModelAndViewContainer;
-import org.springframework.web.server.ResponseStatusException;
-
-import java.util.Arrays;
-
-@Slf4j
-@Component
-@RequiredArgsConstructor
-public class PrincipalArgumentResolver implements HandlerMethodArgumentResolver {
-
- public static final String AUTH_TOKEN_COOKIE_NAME = "PATRIX-DATA-MULTI-AUTH-TOKEN";
-
- private final UserService userService;
-
- @Override
- public boolean supportsParameter(final MethodParameter parameter) {
- return parameter.getParameterType() == Principal.class;
- }
-
- @Override
- public Principal resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer, final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) {
- final boolean required = !parameter.hasParameterAnnotation(PrincipalNotRequired.class);
- final HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
- if (request == null) {
- throw new RuntimeException("HttpServletRequest is required");
- }
- log.debug("Principal: path={}", request.getRequestURL().toString());
-
- final Principal principal;
- if (request.getCookies() == null) {
- log.debug("Principal: No cookies received.");
- principal = null;
- } else {
- final String token = Arrays.stream(request.getCookies()).filter(c -> AUTH_TOKEN_COOKIE_NAME.equals(c.getName())).findFirst().map(Cookie::getValue).orElse("");
- if (token.isEmpty()) {
- log.debug("Principal: Token not set.");
- principal = null;
- } else {
- principal = userService.findPrincipalByToken(token).orElse(null);
- }
- }
- if (principal == null) {
- if (required) {
- log.warn("Principal: Not set but REQUIRED");
- throw new ResponseStatusException(HttpStatus.FORBIDDEN);
- } else {
- log.debug("Principal: Not set but NOT required");
- }
- }
- return principal;
- }
-
-}
diff --git a/src/main/java/de/ph87/data/user/PrincipalNotRequired.java b/src/main/java/de/ph87/data/user/PrincipalNotRequired.java
deleted file mode 100644
index 22b4321..0000000
--- a/src/main/java/de/ph87/data/user/PrincipalNotRequired.java
+++ /dev/null
@@ -1,9 +0,0 @@
-package de.ph87.data.user;
-
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-
-@Retention(RetentionPolicy.RUNTIME)
-public @interface PrincipalNotRequired {
-
-}
diff --git a/src/main/java/de/ph87/data/user/User.java b/src/main/java/de/ph87/data/user/User.java
deleted file mode 100644
index 1f2d39c..0000000
--- a/src/main/java/de/ph87/data/user/User.java
+++ /dev/null
@@ -1,62 +0,0 @@
-package de.ph87.data.user;
-
-import jakarta.persistence.Column;
-import jakarta.persistence.ElementCollection;
-import jakarta.persistence.Entity;
-import jakarta.persistence.GeneratedValue;
-import jakarta.persistence.GenerationType;
-import jakarta.persistence.Id;
-import jakarta.persistence.Table;
-import jakarta.persistence.Version;
-import lombok.Getter;
-import lombok.NoArgsConstructor;
-import lombok.NonNull;
-import lombok.Setter;
-import lombok.ToString;
-
-import java.time.ZonedDateTime;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.UUID;
-
-@Entity
-@Getter
-@Table(name = "`user`")
-@ToString(onlyExplicitlyIncluded = true)
-@NoArgsConstructor
-public class User {
-
- @Id
- @ToString.Include
- @GeneratedValue(strategy = GenerationType.IDENTITY)
- private long id;
-
- @Version
- @ToString.Include
- private long version;
-
- @NonNull
- @ToString.Include
- @Column(nullable = false, unique = true)
- private String uuid = UUID.randomUUID().toString();
-
- @NonNull
- @ToString.Include
- @Column(nullable = false, unique = true)
- private String username;
-
- @Setter
- @NonNull
- @Column(nullable = false)
- private String password;
-
- @NonNull
- @ElementCollection
- private Map tokens = new HashMap<>();
-
- public User(final @NonNull UserCreate create, final @NonNull String password) {
- this.username = create.getUsername();
- this.password = password;
- }
-
-}
diff --git a/src/main/java/de/ph87/data/user/UserController.java b/src/main/java/de/ph87/data/user/UserController.java
deleted file mode 100644
index 0e5b245..0000000
--- a/src/main/java/de/ph87/data/user/UserController.java
+++ /dev/null
@@ -1,76 +0,0 @@
-package de.ph87.data.user;
-
-import jakarta.annotation.Nullable;
-import jakarta.servlet.http.Cookie;
-import jakarta.servlet.http.HttpServletResponse;
-import lombok.NonNull;
-import lombok.RequiredArgsConstructor;
-import org.springframework.http.HttpStatus;
-import org.springframework.web.bind.annotation.CrossOrigin;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
-import org.springframework.web.server.ResponseStatusException;
-
-import static de.ph87.data.user.PrincipalArgumentResolver.AUTH_TOKEN_COOKIE_NAME;
-import static de.ph87.data.user.Helpers.map;
-
-@CrossOrigin
-@RestController
-@RequiredArgsConstructor
-@RequestMapping("User")
-public class UserController {
-
- private final UserService userService;
-
- @NonNull
- @PostMapping("create")
- public UserDto create(@RequestBody final UserCreate create, final HttpServletResponse response) {
- try {
- final Principal principal = userService.create(create);
- response.addCookie(new Cookie(AUTH_TOKEN_COOKIE_NAME, principal.token));
- return principal.user;
- } catch (UserDuplicateError | UserInsecurePassword e) {
- throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
- }
- }
-
- @Nullable
- @GetMapping("whoAmI")
- public UserDto whoAmI(@PrincipalNotRequired @Nullable final Principal principal) {
- return map(principal, Principal::getUser);
- }
-
- @NonNull
- @PostMapping("login")
- public UserDto login(@PrincipalNotRequired @Nullable final Principal oldPrincipal, @RequestBody final UserLogin login, final HttpServletResponse response) {
- if (oldPrincipal != null) {
- userService.logout(oldPrincipal);
- }
- try {
- final Principal principal = userService.login(login);
- response.addCookie(new Cookie(AUTH_TOKEN_COOKIE_NAME, principal.token));
- return principal.user;
- } catch (UserNotFound | UserWrongPassword e) {
- throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
- }
- }
-
- @GetMapping("logout")
- public void logout(final Principal principal) {
- userService.logout(principal);
- }
-
- @NonNull
- @PostMapping("setPassword")
- public UserDto setPassword(@NonNull final Principal principal, @RequestBody final String password) {
- try {
- return userService.setPassword(principal, password);
- } catch (UserInsecurePassword e) {
- throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
- }
- }
-
-}
diff --git a/src/main/java/de/ph87/data/user/UserCreate.java b/src/main/java/de/ph87/data/user/UserCreate.java
deleted file mode 100644
index 2be6f04..0000000
--- a/src/main/java/de/ph87/data/user/UserCreate.java
+++ /dev/null
@@ -1,19 +0,0 @@
-package de.ph87.data.user;
-
-import lombok.AllArgsConstructor;
-import lombok.Getter;
-import lombok.NonNull;
-import lombok.ToString;
-
-@Getter
-@ToString
-@AllArgsConstructor
-public class UserCreate {
-
- @NonNull
- private String username;
-
- @NonNull
- private String password;
-
-}
diff --git a/src/main/java/de/ph87/data/user/UserDto.java b/src/main/java/de/ph87/data/user/UserDto.java
deleted file mode 100644
index e6d011a..0000000
--- a/src/main/java/de/ph87/data/user/UserDto.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package de.ph87.data.user;
-
-import com.fasterxml.jackson.annotation.JsonProperty;
-import lombok.Data;
-import lombok.NonNull;
-
-@Data
-public class UserDto {
-
- @NonNull
- public final String uuid;
-
- @NonNull
- public final String username;
-
- public UserDto(@NonNull final User user) {
- this.uuid = user.getUuid();
- this.username = user.getUsername();
- }
-
- @SuppressWarnings("unused") // used in tests
- private UserDto(
- @JsonProperty("uuid") @NonNull final String uuid,
- @JsonProperty("username") @NonNull final String username
- ) {
- this.uuid = uuid;
- this.username = username;
- }
-
-}
diff --git a/src/main/java/de/ph87/data/user/UserDuplicateError.java b/src/main/java/de/ph87/data/user/UserDuplicateError.java
deleted file mode 100644
index 7e29d9d..0000000
--- a/src/main/java/de/ph87/data/user/UserDuplicateError.java
+++ /dev/null
@@ -1,5 +0,0 @@
-package de.ph87.data.user;
-
-public class UserDuplicateError extends Exception {
-
-}
diff --git a/src/main/java/de/ph87/data/user/UserInsecurePassword.java b/src/main/java/de/ph87/data/user/UserInsecurePassword.java
deleted file mode 100644
index 04390cb..0000000
--- a/src/main/java/de/ph87/data/user/UserInsecurePassword.java
+++ /dev/null
@@ -1,5 +0,0 @@
-package de.ph87.data.user;
-
-public class UserInsecurePassword extends Exception {
-
-}
diff --git a/src/main/java/de/ph87/data/user/UserLogin.java b/src/main/java/de/ph87/data/user/UserLogin.java
deleted file mode 100644
index d537df9..0000000
--- a/src/main/java/de/ph87/data/user/UserLogin.java
+++ /dev/null
@@ -1,20 +0,0 @@
-package de.ph87.data.user;
-
-import lombok.Data;
-import lombok.NonNull;
-
-@Data
-public class UserLogin {
-
- @NonNull
- public final String username;
-
- @NonNull
- public final String password;
-
- public UserLogin(@NonNull final String username, @NonNull final String password) {
- this.username = username;
- this.password = password;
- }
-
-}
diff --git a/src/main/java/de/ph87/data/user/UserNotFound.java b/src/main/java/de/ph87/data/user/UserNotFound.java
deleted file mode 100644
index 1c54274..0000000
--- a/src/main/java/de/ph87/data/user/UserNotFound.java
+++ /dev/null
@@ -1,5 +0,0 @@
-package de.ph87.data.user;
-
-public class UserNotFound extends Exception {
-
-}
diff --git a/src/main/java/de/ph87/data/user/UserRepository.java b/src/main/java/de/ph87/data/user/UserRepository.java
deleted file mode 100644
index d422bd5..0000000
--- a/src/main/java/de/ph87/data/user/UserRepository.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package de.ph87.data.user;
-
-import lombok.NonNull;
-import org.springframework.data.jpa.repository.Query;
-import org.springframework.data.repository.ListCrudRepository;
-
-import java.util.Optional;
-
-public interface UserRepository extends ListCrudRepository {
-
- Optional findByUsername(@NonNull String username);
-
- @Query("select u from User u join u.tokens t where key(t) = :token")
- Optional findByToken(@NonNull String token);
-
-}
diff --git a/src/main/java/de/ph87/data/user/UserService.java b/src/main/java/de/ph87/data/user/UserService.java
deleted file mode 100644
index b1a2b42..0000000
--- a/src/main/java/de/ph87/data/user/UserService.java
+++ /dev/null
@@ -1,119 +0,0 @@
-package de.ph87.data.user;
-
-import lombok.NonNull;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.security.crypto.password.PasswordEncoder;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.time.Duration;
-import java.time.ZonedDateTime;
-import java.util.Optional;
-import java.util.UUID;
-
-@Slf4j
-@Service
-@RequiredArgsConstructor
-public class UserService {
-
- private static final Duration TOKEN_TIMEOUT = Duration.ofDays(3);
-
- private final UserRepository userRepository;
-
- private final PasswordEncoder passwordEncoder;
-
- @NonNull
- @Transactional
- public Principal create(final @NonNull UserCreate create) throws UserDuplicateError, UserInsecurePassword {
- final Optional existing = userRepository.findByUsername(create.getUsername());
- if (existing.isPresent()) {
- log.warn("create: Duplicate username: existing={}", existing.get());
- throw new UserDuplicateError();
- }
- final User user = userRepository.save(new User(create, encodePassword(create.getPassword())));
- log.info("create: User created: user={}", user);
- return createToken(user);
- }
-
- @NonNull
- @Transactional
- public Principal login(@NonNull final UserLogin login) throws UserNotFound, UserWrongPassword {
- final User user = userRepository.findByUsername(login.username).orElse(null);
- if (user == null) {
- passwordEncoder.matches(login.password, ""); // make a dummy check to fake runtime for bruteforce attacks
- log.warn("login: User not found: username=\"{}\"", login.username);
- throw new UserNotFound();
- }
- if (!passwordEncoder.matches(login.password, user.getPassword())) {
- log.warn("login: Wrong password: user={}", user);
- throw new UserWrongPassword();
- }
- log.info("login: User logged in: user={}", user);
- return createToken(user);
- }
-
- @NonNull
- private static Principal createToken(@NonNull final User user) {
- final String token = UUID.randomUUID().toString();
- user.getTokens().put(token, ZonedDateTime.now());
- return new Principal(token, user);
- }
-
- @NonNull
- @Transactional
- public UserDto setPassword(@NonNull final Principal principal, @NonNull final String password) throws UserInsecurePassword {
- return set(principal, u -> u.setPassword(encodePassword(password)));
- }
-
- @NonNull
- private UserDto set(@NonNull final Principal principal, @NonNull final ConsumerWithException modifier) throws E {
- final User user = userRepository.findById(principal.id).orElseThrow();
- modifier.accept(user);
- return new UserDto(user);
- }
-
- @NonNull
- private String encodePassword(@NonNull final String password) throws UserInsecurePassword {
- if (password.length() < 12) {
- throw new UserInsecurePassword();
- }
- return passwordEncoder.encode(password);
- }
-
- @NonNull
- @Transactional
- public Optional findPrincipalByToken(@NonNull final String token) {
- final Optional userOptional = userRepository.findByToken(token);
- if (userOptional.isEmpty()) {
- log.debug("findByToken: No user found by given token!");
- return Optional.empty();
- }
-
- final User user = userOptional.get();
- final ZonedDateTime last = user.getTokens().get(token);
- if (last == null) {
- throw new RuntimeException("Fetched User by token, but token isn't present in users tokens!");
- }
-
- final ZonedDateTime earliest = ZonedDateTime.now().minus(TOKEN_TIMEOUT);
- if (last.isBefore(earliest)) {
- user.getTokens().remove(token);
- log.debug("findByToken: Token expired: user={}", user);
- return Optional.empty();
- }
-
- log.info("findByToken: Renewed: user={}", user);
- user.getTokens().put(token, ZonedDateTime.now());
- return Optional.of(new Principal(token, user));
- }
-
- @Transactional
- public void logout(@NonNull final Principal principal) {
- set(principal, user -> {
- user.getTokens().remove(principal.token);
- log.info("logout: user={}", user);
- });
- }
-
-}
diff --git a/src/main/java/de/ph87/data/user/UserWrongPassword.java b/src/main/java/de/ph87/data/user/UserWrongPassword.java
deleted file mode 100644
index 2c85829..0000000
--- a/src/main/java/de/ph87/data/user/UserWrongPassword.java
+++ /dev/null
@@ -1,5 +0,0 @@
-package de.ph87.data.user;
-
-public class UserWrongPassword extends Exception {
-
-}
diff --git a/src/main/java/de/ph87/data/user/WebConfig.java b/src/main/java/de/ph87/data/user/WebConfig.java
deleted file mode 100644
index 2a74a7c..0000000
--- a/src/main/java/de/ph87/data/user/WebConfig.java
+++ /dev/null
@@ -1,22 +0,0 @@
-package de.ph87.data.user;
-
-import lombok.RequiredArgsConstructor;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.web.method.support.HandlerMethodArgumentResolver;
-import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
-
-import java.util.List;
-
-@Configuration
-@RequiredArgsConstructor
-public class WebConfig implements WebMvcConfigurer {
-
- private final PrincipalArgumentResolver principalArgumentResolver;
-
- @Override
- public void addArgumentResolvers(List resolvers) {
- resolvers.add(principalArgumentResolver);
- }
-
-}
-
diff --git a/src/main/java/de/ph87/data/websocket/IWebsocketMessage.java b/src/main/java/de/ph87/data/websocket/IWebsocketMessage.java
new file mode 100644
index 0000000..164eb70
--- /dev/null
+++ b/src/main/java/de/ph87/data/websocket/IWebsocketMessage.java
@@ -0,0 +1,30 @@
+package de.ph87.data.websocket;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import lombok.NonNull;
+
+public interface IWebsocketMessage {
+
+ String DTO_SUFFIX = "Dto";
+
+ @NonNull
+ @JsonIgnore
+ default String getWebsocketTopic() {
+ return getNameWithoutDtoSuffix();
+ }
+
+ @NonNull
+ default String getNameWithoutDtoSuffix() {
+ final String name = getClass().getSimpleName();
+ if (name.endsWith(DTO_SUFFIX)) {
+ return name.substring(0, name.length() - DTO_SUFFIX.length());
+ }
+ return name;
+ }
+
+ @SuppressWarnings("unused") // json
+ default String get_type_() {
+ return getNameWithoutDtoSuffix();
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/websocket/WebSocketConfig.java b/src/main/java/de/ph87/data/websocket/WebSocketConfig.java
new file mode 100644
index 0000000..1e0d317
--- /dev/null
+++ b/src/main/java/de/ph87/data/websocket/WebSocketConfig.java
@@ -0,0 +1,40 @@
+package de.ph87.data.websocket;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.messaging.simp.config.MessageBrokerRegistry;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+import org.springframework.web.bind.annotation.CrossOrigin;
+import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
+import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
+import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
+
+@CrossOrigin
+@Configuration
+@EnableWebSocketMessageBroker
+public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
+
+ public static final String DESTINATION = "";
+
+ @Override
+ public void registerStompEndpoints(StompEndpointRegistry registry) {
+ registry.addEndpoint("/websocket").setAllowedOrigins("*");
+ }
+
+ @Override
+ public void configureMessageBroker(MessageBrokerRegistry config) {
+ config.enableSimpleBroker(DESTINATION).setHeartbeatValue(new long[]{2000, 2000}).setTaskScheduler(heartBeatScheduler());
+ }
+
+ @Bean
+ public ThreadPoolTaskScheduler heartBeatScheduler() {
+ final ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
+ scheduler.setThreadNamePrefix("wss-heartbeat-");
+ scheduler.setPoolSize(1);
+ scheduler.setRemoveOnCancelPolicy(true);
+ scheduler.setAwaitTerminationSeconds(5);
+ scheduler.setWaitForTasksToCompleteOnShutdown(true);
+ return scheduler;
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/websocket/WebSocketService.java b/src/main/java/de/ph87/data/websocket/WebSocketService.java
new file mode 100644
index 0000000..10f6148
--- /dev/null
+++ b/src/main/java/de/ph87/data/websocket/WebSocketService.java
@@ -0,0 +1,23 @@
+package de.ph87.data.websocket;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.lang.NonNull;
+import org.springframework.messaging.simp.SimpMessageSendingOperations;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.event.TransactionalEventListener;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class WebSocketService {
+
+ private final SimpMessageSendingOperations simpMessageSendingOperations;
+
+ @TransactionalEventListener(IWebsocketMessage.class)
+ public void send(@NonNull final IWebsocketMessage message) {
+ log.debug("Websocket: topic={}, payload={}", message.getWebsocketTopic(), message);
+ simpMessageSendingOperations.convertAndSend(message.getWebsocketTopic(), message);
+ }
+
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
new file mode 100644
index 0000000..6c7646f
--- /dev/null
+++ b/src/main/resources/application.properties
@@ -0,0 +1,6 @@
+logging.level.root=WARN
+logging.level.de.ph87=INFO
+logging.level.calimero=OFF
+#-
+spring.main.banner-mode=off
+spring.jpa.open-in-view=false
diff --git a/src/test/java/de/ph87/data/user/UserControllerTest.java b/src/test/java/de/ph87/data/user/UserControllerTest.java
deleted file mode 100644
index a652064..0000000
--- a/src/test/java/de/ph87/data/user/UserControllerTest.java
+++ /dev/null
@@ -1,99 +0,0 @@
-package de.ph87.data.user;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-import jakarta.servlet.http.Cookie;
-import lombok.NonNull;
-import org.junit.jupiter.api.Test;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
-import org.springframework.boot.test.context.SpringBootTest;
-import org.springframework.http.MediaType;
-import org.springframework.mock.web.MockHttpServletResponse;
-import org.springframework.test.web.servlet.MockMvc;
-import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNotEquals;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-
-@SuppressWarnings("SameParameterValue")
-@SpringBootTest
-@AutoConfigureMockMvc
-public class UserControllerTest {
-
- private static final String USERNAME = "test-username";
-
- private static final String PASSWORD = "test-password";
-
- @Autowired
- private MockMvc mockMvc;
-
- @Autowired
- private ObjectMapper objectMapper;
-
- @Test
- void crud() throws Exception {
- Cookie[] cookies = new Cookie[0];
- anonymous(cookies, "/User/whoAmI");
- cookies = create(cookies);
- online(cookies, USERNAME);
- anonymous(cookies, "/User/logout");
- anonymous(cookies, "/User/whoAmI");
- cookies = login(cookies, USERNAME, PASSWORD);
- online(cookies, USERNAME);
- }
-
- private void anonymous(final Cookie[] cookies, final String path) throws Exception {
- final MockHttpServletRequestBuilder request = get(path);
- if (cookies.length > 0) {
- request.cookie(cookies);
- }
- final MockHttpServletResponse response = mockMvc.perform(request).andExpect(status().isOk()).andReturn().getResponse();
- assertEquals("", response.getContentAsString());
- response.getCookies();
- }
-
- private Cookie[] create(final Cookie[] cookies) throws Exception {
- final MockHttpServletRequestBuilder create = post("/User/create");
- if (cookies.length > 0) {
- create.cookie(cookies);
- }
- create.contentType(MediaType.APPLICATION_JSON);
- create.content(objectMapper.writeValueAsString(new UserCreate(USERNAME, PASSWORD)));
- return assertUser(create, USERNAME);
- }
-
- private Cookie[] login(final Cookie[] cookies, final String username, final String password) throws Exception {
- final MockHttpServletRequestBuilder request = post("/User/login");
- if (cookies.length > 0) {
- request.cookie(cookies);
- }
- request.contentType(MediaType.APPLICATION_JSON);
- request.content(objectMapper.writeValueAsString(new UserLogin(username, password)));
- return assertUser(request, username);
- }
-
- private void online(final Cookie[] cookies, final String username) throws Exception {
- final MockHttpServletRequestBuilder request = get("/User/whoAmI");
- if (cookies.length > 0) {
- request.cookie(cookies);
- }
- assertUser(request, username);
- }
-
- @NonNull
- private Cookie[] assertUser(final MockHttpServletRequestBuilder request, @NonNull final String username) throws Exception {
- final MockHttpServletResponse response = mockMvc.perform(request).andExpect(status().isOk()).andReturn().getResponse();
- final String payload = response.getContentAsString();
- assertNotNull(payload);
- assertNotEquals("", payload);
- final UserDto userDto = objectMapper.readValue(payload, UserDto.class);
- assertEquals(36, userDto.uuid.length());
- assertEquals(username, userDto.username);
- return response.getCookies();
- }
-
-}
\ No newline at end of file