Mqtt, Topic, TopicQuery, Series, Bool, Delta, Varying, Websocket

This commit is contained in:
Patrick Haßel 2025-09-15 14:39:39 +02:00
parent d73c1acbe4
commit 41a645227c
78 changed files with 2274 additions and 596 deletions

View File

@ -1 +1,11 @@
logging.level.de.ph87=DEBUG
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

View File

@ -38,10 +38,6 @@
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
</dependency>
<!-- <dependency>-->
<!-- <groupId>org.springframework.boot</groupId>-->
<!-- <artifactId>spring-boot-starter-security</artifactId>-->
<!-- </dependency>-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@ -58,6 +54,11 @@
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.eclipse.paho</groupId>
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
<version>1.2.5</version>
</dependency>
</dependencies>
</project>

View File

@ -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));
}
}

View File

@ -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 <T, R> R map(@Nullable T t, @NonNull final Function<T, R> map) {
public static <T, R> R map(@Nullable final T t, @NonNull final Function<T, R> mapper) {
if (t == null) {
return null;
}
return map.apply(t);
return mapper.apply(t);
}
}

View File

@ -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<LogMessage> 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();
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,5 @@
package de.ph87.data.log;
public enum LogSeverity {
ERROR, WARN, INFO, DEBUG
}

View File

@ -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;
}
}

View File

@ -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());
}
}
}
}
}

View File

@ -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;
}
}
}

View File

@ -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<SeriesDto> 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);
}
}

View File

@ -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();
}
}

View File

@ -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;
}

View File

@ -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<SeriesPoint> {
@Override
public void serialize(final SeriesPoint seriesPoint, final JsonGenerator jsonGenerator, final SerializerProvider serializerProvider) throws IOException {
jsonGenerator.writeStartArray();
seriesPoint.toJson(jsonGenerator);
jsonGenerator.writeEndArray();
}
}

View File

@ -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);
}
}

View File

@ -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<Series, Long> {
@NonNull
Optional<Series> 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<SeriesDto> findAllDto();
}

View File

@ -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);
};
}
}

View File

@ -0,0 +1,5 @@
package de.ph87.data.series;
public enum SeriesType {
BOOL, DELTA, VARYING
}

View File

@ -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;
}
}

View File

@ -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<ZonedDateTime, ZonedDateTime> align;
public final ChronoUnit unit;
public final int amount;
Interval(final Function<ZonedDateTime, ZonedDateTime> align, @NonNull final ChronoUnit unit, final int amount) {
this.align = align;
this.unit = unit;
this.amount = amount;
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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<Bool, DataId> {
@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<BoolPoint> points(@NonNull Series series, @NonNull ZonedDateTime first, @NonNull ZonedDateTime after);
}

View File

@ -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<BoolPoint> points(@NonNull final Series series, @NonNull final SeriesPointsRequest request) {
return boolRepo.points(series, request.first, request.after);
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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<T extends Delta> extends CrudRepository<T, DataId> {
@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<DeltaPoint> points(@NonNull Series series, @NonNull ZonedDateTime first, @NonNull ZonedDateTime after);
}

View File

@ -0,0 +1,5 @@
package de.ph87.data.series.data.delta;
public interface DeltaRepoDay extends DeltaRepo<Delta.Day> {
}

View File

@ -0,0 +1,5 @@
package de.ph87.data.series.data.delta;
public interface DeltaRepoFive extends DeltaRepo<Delta.Five> {
}

View File

@ -0,0 +1,5 @@
package de.ph87.data.series.data.delta;
public interface DeltaRepoHour extends DeltaRepo<Delta.Hour> {
}

View File

@ -0,0 +1,5 @@
package de.ph87.data.series.data.delta;
public interface DeltaRepoMonth extends DeltaRepo<Delta.Month> {
}

View File

@ -0,0 +1,5 @@
package de.ph87.data.series.data.delta;
public interface DeltaRepoWeek extends DeltaRepo<Delta.Week> {
}

View File

@ -0,0 +1,5 @@
package de.ph87.data.series.data.delta;
public interface DeltaRepoYear extends DeltaRepo<Delta.Year> {
}

View File

@ -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 <DELTA extends Delta, DTO extends DeltaDto> void write(@NonNull final Series series, @NonNull final DeltaRepo<DELTA> repo, @NonNull final Interval interval, @NonNull final ZonedDateTime date, final double value, @NonNull final BiFunction<DataId, Double, DELTA> create, @NonNull final Function<DELTA, DTO> 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<DeltaPoint> 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);
};
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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<T extends Varying> extends CrudRepository<T, DataId> {
@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<VaryingPoint> points(@NonNull Series series, @NonNull ZonedDateTime first, @NonNull ZonedDateTime after);
}

View File

@ -0,0 +1,5 @@
package de.ph87.data.series.data.varying;
public interface VaryingRepoDay extends VaryingRepo<Varying.Day> {
}

View File

@ -0,0 +1,5 @@
package de.ph87.data.series.data.varying;
public interface VaryingRepoFive extends VaryingRepo<Varying.Five> {
}

View File

@ -0,0 +1,5 @@
package de.ph87.data.series.data.varying;
public interface VaryingRepoHour extends VaryingRepo<Varying.Hour> {
}

View File

@ -0,0 +1,5 @@
package de.ph87.data.series.data.varying;
public interface VaryingRepoMonth extends VaryingRepo<Varying.Month> {
}

View File

@ -0,0 +1,5 @@
package de.ph87.data.series.data.varying;
public interface VaryingRepoWeek extends VaryingRepo<Varying.Week> {
}

View File

@ -0,0 +1,5 @@
package de.ph87.data.series.data.varying;
public interface VaryingRepoYear extends VaryingRepo<Varying.Year> {
}

View File

@ -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 <VARYING extends Varying, DTO extends VaryingDto> void write(@NonNull final Series series, @NonNull final VaryingRepo<VARYING> repo, @NonNull final Interval interval, @NonNull final ZonedDateTime date, final double value, @NonNull final BiFunction<DataId, Double, VARYING> create, @NonNull final Function<VARYING, DTO> 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<VaryingPoint> 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);
};
}
}

View File

@ -0,0 +1,5 @@
package de.ph87.data.topic;
public enum TimestampType {
EPOCH_MILLISECONDS, EPOCH_SECONDS
}

View File

@ -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<TopicQuery> 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++;
}
}

View File

@ -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<TopicDto> 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));
}
}

View File

@ -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<TopicQueryDto> 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();
}
}

View File

@ -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());
};
}
}

View File

@ -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<Topic, Long> {
@NonNull
Optional<Topic> findByName(@NonNull String name);
@Query("select new de.ph87.data.topic.TopicDto(t) from Topic t")
List<TopicDto> findAllDto();
}

View File

@ -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<Topic> 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;
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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<Double, Double> function;
TopicQueryFunction(@NonNull Function<Double, Double> function) {
this.function = function;
}
public double apply(final double value) {
return function.apply(value);
}
}

View File

@ -1,8 +0,0 @@
package de.ph87.data.user;
@FunctionalInterface
public interface ConsumerWithException<T, E extends Exception> {
void accept(final T t) throws E;
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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 {
}

View File

@ -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<String, ZonedDateTime> tokens = new HashMap<>();
public User(final @NonNull UserCreate create, final @NonNull String password) {
this.username = create.getUsername();
this.password = password;
}
}

View File

@ -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());
}
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -1,5 +0,0 @@
package de.ph87.data.user;
public class UserDuplicateError extends Exception {
}

View File

@ -1,5 +0,0 @@
package de.ph87.data.user;
public class UserInsecurePassword extends Exception {
}

View File

@ -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;
}
}

View File

@ -1,5 +0,0 @@
package de.ph87.data.user;
public class UserNotFound extends Exception {
}

View File

@ -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<User, Long> {
Optional<User> findByUsername(@NonNull String username);
@Query("select u from User u join u.tokens t where key(t) = :token")
Optional<User> findByToken(@NonNull String token);
}

View File

@ -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<User> 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 <E extends Exception> UserDto set(@NonNull final Principal principal, @NonNull final ConsumerWithException<User, E> 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<Principal> findPrincipalByToken(@NonNull final String token) {
final Optional<User> 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);
});
}
}

View File

@ -1,5 +0,0 @@
package de.ph87.data.user;
public class UserWrongPassword extends Exception {
}

View File

@ -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<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(principalArgumentResolver);
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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

View File

@ -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();
}
}