From 908134600c5719d98b07102423ad8956e43b987d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20Ha=C3=9Fel?= Date: Tue, 18 Feb 2025 10:49:28 +0100 Subject: [PATCH] EntryController + Graph --- application.properties | 2 +- .../data/message/handler/EspHomeHandler.java | 5 +- .../data/message/handler/HeizungHandler.java | 5 +- .../data/message/handler/OpenDTUHandler.java | 8 +- .../message/handler/SimpleJsonHandler.java | 5 +- .../message/handler/SmartMeterHandler.java | 9 +- src/main/java/de/ph87/data/series/Series.java | 4 + .../java/de/ph87/data/series/SeriesDto.java | 11 ++ .../de/ph87/data/series/SeriesService.java | 36 ++-- .../java/de/ph87/data/series/entry/Entry.java | 6 +- .../data/series/entry/EntryRepository.java | 2 + .../ph87/data/series/entry/EntryService.java | 15 +- .../java/de/ph87/data/series/graph/Graph.java | 161 ++++++++++++++++++ .../data/series/graph/GraphController.java | 32 ++++ .../ph87/data/series/graph/GraphService.java | 29 ++++ src/main/java/de/ph87/data/unit/Unit.java | 1 + 16 files changed, 291 insertions(+), 40 deletions(-) create mode 100644 src/main/java/de/ph87/data/series/graph/Graph.java create mode 100644 src/main/java/de/ph87/data/series/graph/GraphController.java create mode 100644 src/main/java/de/ph87/data/series/graph/GraphService.java diff --git a/application.properties b/application.properties index f75e180..ed0e47c 100644 --- a/application.properties +++ b/application.properties @@ -5,7 +5,7 @@ spring.datasource.driverClassName=org.h2.Driver spring.datasource.username=sa spring.datasource.password=password #- -spring.jpa.hibernate.ddl-auto=create +#spring.jpa.hibernate.ddl-auto=create #- de.ph87.data.message.receive.mqtt.host=10.0.0.50 de.ph87.data.message.receive.mqtt.topic=# diff --git a/src/main/java/de/ph87/data/message/handler/EspHomeHandler.java b/src/main/java/de/ph87/data/message/handler/EspHomeHandler.java index fd4ef4f..05a4d60 100644 --- a/src/main/java/de/ph87/data/message/handler/EspHomeHandler.java +++ b/src/main/java/de/ph87/data/message/handler/EspHomeHandler.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.annotation.*; import de.ph87.data.message.*; import de.ph87.data.series.*; +import de.ph87.data.series.entry.*; import de.ph87.data.unit.Value; import de.ph87.data.unit.*; import lombok.*; @@ -25,7 +26,7 @@ public class EspHomeHandler implements IMessageHandler { private final ObjectMapper objectMapper; - private final SeriesService seriesService; + private final EntryService entryService; @Override public void handle(@NonNull final Message message) throws Exception { @@ -58,7 +59,7 @@ public class EspHomeHandler implements IMessageHandler { return; } final Value value = new Value(inbound.value, unitFromPayload); - seriesService.receive(new SeriesInbound(name, inbound.date, value.as(targetUnit))); + entryService.receive(new SeriesInbound(name, inbound.date, value.as(targetUnit))); } private String propertyReplace(final String property) { diff --git a/src/main/java/de/ph87/data/message/handler/HeizungHandler.java b/src/main/java/de/ph87/data/message/handler/HeizungHandler.java index 4e4ddda..89b3c92 100644 --- a/src/main/java/de/ph87/data/message/handler/HeizungHandler.java +++ b/src/main/java/de/ph87/data/message/handler/HeizungHandler.java @@ -3,6 +3,7 @@ package de.ph87.data.message.handler; import com.fasterxml.jackson.databind.*; import de.ph87.data.message.*; import de.ph87.data.series.*; +import de.ph87.data.series.entry.*; import de.ph87.data.unit.Value; import de.ph87.data.unit.*; import lombok.*; @@ -21,7 +22,7 @@ public class HeizungHandler implements IMessageHandler { private final ObjectMapper objectMapper; - private final SeriesService seriesService; + private final EntryService entryService; @Override public void handle(@NonNull final Message message) throws Exception { @@ -31,7 +32,7 @@ public class HeizungHandler implements IMessageHandler { } final String property = matcher.group("property"); final Inbound inbound = objectMapper.readValue(message.payload, Inbound.class); - seriesService.receive(new SeriesInbound(property, inbound.date, inbound.value)); + entryService.receive(new SeriesInbound(property, inbound.date, inbound.value)); } @Getter diff --git a/src/main/java/de/ph87/data/message/handler/OpenDTUHandler.java b/src/main/java/de/ph87/data/message/handler/OpenDTUHandler.java index 0371016..4030f11 100644 --- a/src/main/java/de/ph87/data/message/handler/OpenDTUHandler.java +++ b/src/main/java/de/ph87/data/message/handler/OpenDTUHandler.java @@ -3,6 +3,7 @@ package de.ph87.data.message.handler; import com.fasterxml.jackson.databind.*; import de.ph87.data.message.*; import de.ph87.data.series.*; +import de.ph87.data.series.entry.*; import de.ph87.data.unit.Value; import de.ph87.data.unit.*; import lombok.*; @@ -18,7 +19,7 @@ public class OpenDTUHandler implements IMessageHandler { private final ObjectMapper objectMapper; - private final SeriesService seriesService; + private final EntryService entryService; @Override public void handle(final @NonNull Message message) throws Exception { @@ -26,11 +27,10 @@ public class OpenDTUHandler implements IMessageHandler { return; } final Inbound inbound = objectMapper.readValue(message.payload, Inbound.class); - seriesService.receive(new SeriesInbound("electricity/energy/produced", inbound.date, inbound.energy.as(Unit.ENERGY_KWH))); - seriesService.receive(new SeriesInbound("electricity/power/produced", inbound.date, inbound.power.as(Unit.POWER_W))); + entryService.receive(new SeriesInbound("electricity/energy/produced", inbound.date, inbound.energy.as(Unit.ENERGY_KWH))); + entryService.receive(new SeriesInbound("electricity/power/produced", inbound.date, inbound.power.as(Unit.POWER_W))); } - @Getter @ToString private static class Inbound { diff --git a/src/main/java/de/ph87/data/message/handler/SimpleJsonHandler.java b/src/main/java/de/ph87/data/message/handler/SimpleJsonHandler.java index 5c43be1..c00325f 100644 --- a/src/main/java/de/ph87/data/message/handler/SimpleJsonHandler.java +++ b/src/main/java/de/ph87/data/message/handler/SimpleJsonHandler.java @@ -3,6 +3,7 @@ package de.ph87.data.message.handler; import com.fasterxml.jackson.databind.*; import de.ph87.data.message.*; import de.ph87.data.series.*; +import de.ph87.data.series.entry.*; import de.ph87.data.unit.Value; import de.ph87.data.unit.*; import lombok.*; @@ -18,7 +19,7 @@ public class SimpleJsonHandler implements IMessageHandler { private final ObjectMapper objectMapper; - private final SeriesService seriesService; + private final EntryService entryService; @Override public void handle(@NonNull final Message message) throws Exception { @@ -26,7 +27,7 @@ public class SimpleJsonHandler implements IMessageHandler { return; } final Inbound inbound = objectMapper.readValue(message.payload, Inbound.class); - seriesService.receive(new SeriesInbound(inbound.name, inbound.date, inbound.value)); + entryService.receive(new SeriesInbound(inbound.name, inbound.date, inbound.value)); } @Getter diff --git a/src/main/java/de/ph87/data/message/handler/SmartMeterHandler.java b/src/main/java/de/ph87/data/message/handler/SmartMeterHandler.java index bdeebe7..01e52d1 100644 --- a/src/main/java/de/ph87/data/message/handler/SmartMeterHandler.java +++ b/src/main/java/de/ph87/data/message/handler/SmartMeterHandler.java @@ -3,6 +3,7 @@ package de.ph87.data.message.handler; import com.fasterxml.jackson.databind.*; import de.ph87.data.message.*; import de.ph87.data.series.*; +import de.ph87.data.series.entry.*; import de.ph87.data.unit.Value; import de.ph87.data.unit.*; import lombok.*; @@ -18,7 +19,7 @@ public class SmartMeterHandler implements IMessageHandler { private final ObjectMapper objectMapper; - private final SeriesService seriesService; + private final EntryService entryService; @Override public void handle(@NonNull final Message message) throws Exception { @@ -26,9 +27,9 @@ public class SmartMeterHandler implements IMessageHandler { return; } final Inbound inbound = objectMapper.readValue(message.payload, Inbound.class); - seriesService.receive(new SeriesInbound("electricity/energy/purchased", inbound.date, inbound.energyPurchased.as(Unit.ENERGY_KWH))); - seriesService.receive(new SeriesInbound("electricity/energy/delivered", inbound.date, inbound.energyDelivered.as(Unit.ENERGY_KWH))); - seriesService.receive(new SeriesInbound("electricity/power/difference", inbound.date, inbound.powerDifference.as(Unit.POWER_W))); + entryService.receive(new SeriesInbound("electricity/energy/purchased", inbound.date, inbound.energyPurchased.as(Unit.ENERGY_KWH))); + entryService.receive(new SeriesInbound("electricity/energy/delivered", inbound.date, inbound.energyDelivered.as(Unit.ENERGY_KWH))); + entryService.receive(new SeriesInbound("electricity/power/difference", inbound.date, inbound.powerDifference.as(Unit.POWER_W))); } @Getter diff --git a/src/main/java/de/ph87/data/series/Series.java b/src/main/java/de/ph87/data/series/Series.java index 24c330c..ddddb0b 100644 --- a/src/main/java/de/ph87/data/series/Series.java +++ b/src/main/java/de/ph87/data/series/Series.java @@ -30,6 +30,10 @@ public class Series { @Enumerated(EnumType.STRING) private Unit unit; + @Setter + @Column(nullable = false) + private int decimals = 1; + public Series(@NonNull final String name, @NonNull final Unit unit) { this.name = name; this.title = name; diff --git a/src/main/java/de/ph87/data/series/SeriesDto.java b/src/main/java/de/ph87/data/series/SeriesDto.java index 0e4d598..27adc5b 100644 --- a/src/main/java/de/ph87/data/series/SeriesDto.java +++ b/src/main/java/de/ph87/data/series/SeriesDto.java @@ -1,6 +1,7 @@ package de.ph87.data.series; import de.ph87.data.unit.*; +import jakarta.annotation.*; import lombok.*; @Getter @@ -15,11 +16,21 @@ public class SeriesDto { public final Unit unit; + public final int decimals; + public SeriesDto(@NonNull final Series series) { this.id = series.getId(); this.name = series.getName(); this.title = series.getTitle(); this.unit = series.getUnit(); + this.decimals = series.getDecimals(); + } + + public String format(@Nullable final Double value) { + if (value == null || Double.isNaN(value)) { + return "--- %s".formatted(unit.unit); + } + return "%%.%df %%s".formatted(decimals).formatted(value, unit.unit); } } diff --git a/src/main/java/de/ph87/data/series/SeriesService.java b/src/main/java/de/ph87/data/series/SeriesService.java index 7a5c716..19fd229 100644 --- a/src/main/java/de/ph87/data/series/SeriesService.java +++ b/src/main/java/de/ph87/data/series/SeriesService.java @@ -1,7 +1,6 @@ package de.ph87.data.series; import de.ph87.data.*; -import de.ph87.data.series.entry.*; import de.ph87.data.unit.*; import lombok.*; import lombok.extern.slf4j.*; @@ -18,22 +17,28 @@ public class SeriesService { private final SeriesRepository seriesRepository; - private final EntryService entryService; - - public SeriesDto modify(@NonNull final long id, @NonNull final Consumer modifier) { + @SuppressWarnings("unused") + public SeriesDto modify(final long id, @NonNull final Consumer modifier) { final Series series = getById(id); modifier.accept(series); return publish(series, Action.CHANGED); } - public void delete(@NonNull final long id) { + @SuppressWarnings("unused") + public void delete(final long id) { final Series series = getById(id); + seriesRepository.delete(series); + publish(series, Action.DELETED); } - private Series getById(@NonNull final long id) { + private Series getById(final long id) { return seriesRepository.findById(id).orElseThrow(); } + public SeriesDto getDtoById(final long id) { + return toDto(getById(id)); + } + private SeriesDto publish(@NonNull final Series series, @NonNull final Action action) { final SeriesDto dto = toDto(series); log.info("Series {}: {}", action, series); @@ -44,19 +49,14 @@ public class SeriesService { return new SeriesDto(series); } - public void receive(@NonNull final SeriesInbound measure) throws Unit.NotConvertible { - final Series series = getOrCreate(measure.name, measure.value.unit); - entryService.write(series, measure); - } - - private Series getOrCreate(@NonNull final String name, @NonNull final Unit unit) { + public Series getOrCreate(@NonNull final String name, @NonNull final Unit unit) { return seriesRepository - .findByName(name) - .orElseGet(() -> { - final Series series = seriesRepository.save(new Series(name, unit)); - publish(series, Action.CREATED); - return series; - }); + .findByName(name) + .orElseGet(() -> { + final Series series = seriesRepository.save(new Series(name, unit)); + publish(series, Action.CREATED); + return series; + }); } } diff --git a/src/main/java/de/ph87/data/series/entry/Entry.java b/src/main/java/de/ph87/data/series/entry/Entry.java index 1e50051..340c709 100644 --- a/src/main/java/de/ph87/data/series/entry/Entry.java +++ b/src/main/java/de/ph87/data/series/entry/Entry.java @@ -14,9 +14,9 @@ import java.time.temporal.*; @ToString @NoArgsConstructor @Table( - uniqueConstraints = { - @UniqueConstraint(columnNames = {"series_id", "date"}) - } + uniqueConstraints = { + @UniqueConstraint(columnNames = {"series_id", "date"}) + } ) public class Entry { diff --git a/src/main/java/de/ph87/data/series/entry/EntryRepository.java b/src/main/java/de/ph87/data/series/entry/EntryRepository.java index c713b4f..d4fb1dc 100644 --- a/src/main/java/de/ph87/data/series/entry/EntryRepository.java +++ b/src/main/java/de/ph87/data/series/entry/EntryRepository.java @@ -11,4 +11,6 @@ public interface EntryRepository extends ListCrudRepository { Optional findBySeriesAndDate(@NonNull Series series, @NonNull ZonedDateTime truncated); + List findAllBySeriesIdAndDateGreaterThanEqualAndDateLessThanEqual(long id, @NonNull ZonedDateTime begin, @NonNull ZonedDateTime end); + } diff --git a/src/main/java/de/ph87/data/series/entry/EntryService.java b/src/main/java/de/ph87/data/series/entry/EntryService.java index 2792a7a..d1d5a46 100644 --- a/src/main/java/de/ph87/data/series/entry/EntryService.java +++ b/src/main/java/de/ph87/data/series/entry/EntryService.java @@ -15,15 +15,22 @@ import java.time.temporal.*; @Transactional @RequiredArgsConstructor public class EntryService { - + private final EntryRepository entryRepository; - + + private final SeriesService seriesService; + public void write(@NonNull final Series series, @NonNull final SeriesInbound measure) throws Unit.NotConvertible { - final ZonedDateTime truncated = measure.date.truncatedTo(ChronoUnit.MINUTES); + final ZonedDateTime truncated = measure.date.truncatedTo(ChronoUnit.MINUTES).minusMinutes(measure.date.getMinute() % 5); if (entryRepository.findBySeriesAndDate(series, truncated).isEmpty()) { final Entry created = entryRepository.save(new Entry(series, truncated, measure.value)); log.debug("Created: {}", created); } } - + + public void receive(@NonNull final SeriesInbound measure) throws Unit.NotConvertible { + final Series series = seriesService.getOrCreate(measure.name, measure.value.unit); + write(series, measure); + } + } diff --git a/src/main/java/de/ph87/data/series/graph/Graph.java b/src/main/java/de/ph87/data/series/graph/Graph.java new file mode 100644 index 0000000..8e973cb --- /dev/null +++ b/src/main/java/de/ph87/data/series/graph/Graph.java @@ -0,0 +1,161 @@ +package de.ph87.data.series.graph; + +import de.ph87.data.series.*; +import de.ph87.data.series.entry.*; +import jakarta.annotation.*; +import lombok.*; + +import java.awt.*; +import java.awt.geom.*; +import java.awt.image.*; +import java.time.*; +import java.util.List; +import java.util.function.*; + +import static java.lang.Math.*; + +public class Graph { + + public static final Stroke NORMAL = new BasicStroke(1, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 1); + + public static final Stroke DASHED = new BasicStroke(1, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 1, new float[]{2, 2}, 0); + + @NonNull + public final SeriesDto series; + + @NonNull + public final ZonedDateTime begin; + + @NonNull + public final ZonedDateTime end; + + public final int width; + + public final int height; + + public final int border; + + public final List points; + + public final long minuteMin; + + public final long minuteMax; + + public final long minuteRange; + + public final double minuteScale; + + public final double valueMin; + + public final double valueMax; + + public final double valueAvg; + + public final double valueRange; + + public final double valueScale; + + public final int heightInner; + + public final int widthInner; + + public final int maxLabelWidth; + + public Graph(@NonNull final SeriesDto series, @NonNull final List entries, final @NonNull ZonedDateTime begin, final @NonNull ZonedDateTime end, final int width, final int height, final int border) { + this.series = series; + this.begin = begin; + this.end = end; + this.width = width; + this.height = height; + this.border = border; + + double vSum = 0; + double vMin = Double.MAX_VALUE; + double vMax = Double.MIN_VALUE; + int maxLabelWidth = 80; + final FontMetrics fontMetrics = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB).getGraphics().getFontMetrics(); + for (final Entry entry : entries) { + vMin = Math.min(vMin, entry.getValue()); + vMax = max(vMax, entry.getValue()); + vSum += entry.getValue(); + maxLabelWidth = max(maxLabelWidth, fontMetrics.stringWidth(series.format(entry.getValue()))); + } + this.valueAvg = vSum / entries.size(); + this.maxLabelWidth = maxLabelWidth; + + widthInner = width - 3 * border - maxLabelWidth; + heightInner = height - 2 * border; + + minuteMin = begin.toEpochSecond() / 60; + minuteMax = end.toEpochSecond() / 60; + minuteRange = minuteMax - minuteMin; + minuteScale = (double) widthInner / minuteRange; + + valueMin = vMin; + valueMax = vMax; + valueRange = vMax - vMin; + valueScale = heightInner / valueRange; + + points = entries.stream().map(toPoint(minuteMin, minuteScale, vMin, valueScale)).toList(); + } + + public BufferedImage draw() { + final BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + final Graphics2D g = (Graphics2D) image.getGraphics(); + final int fontH3_4 = (int) Math.round(g.getFontMetrics().getHeight() * 0.75); + + g.setColor(Color.gray); + final String string = "%s [%s]".formatted(series.getTitle(), series.unit.unit); + g.drawString(string, border, border + fontH3_4); + + yLabel(g, valueMax, DASHED, Color.red); + yLabel(g, valueAvg, DASHED, new Color(0, 127, 0)); + yLabel(g, 0, NORMAL, Color.BLACK); + yLabel(g, valueMin, DASHED, Color.blue); + + g.translate(border, height - border); + g.scale(1, -1); + + g.setStroke(NORMAL); + g.setColor(Color.BLACK); + g.drawLine(widthInner, 0, widthInner, heightInner); // y-axis + + Point last = null; + g.setColor(Color.RED); + for (final Point current : points) { + if (last != null && current.x - last.x <= minuteScale * 2) { + g.drawLine(last.x, last.y, current.x, current.y); + } + last = current; + } + return image; + } + + private void yLabel(final Graphics2D g, final double value, @Nullable final Stroke stroke, final @Nullable Color color) { + final String string = series.format(value); + final int offset = maxLabelWidth - g.getFontMetrics().stringWidth(string); + final int y = height - ((int) Math.round((value - valueMin) * valueScale) + border); + g.setColor(color); + g.drawString(string, widthInner + 2 * border + offset, y + (int) Math.round(g.getFontMetrics().getHeight() * 0.25)); + if (stroke != null && color != null) { + g.setStroke(stroke); + g.draw(new Line2D.Double(border, y, width - maxLabelWidth - border * 1.5, y)); + } + } + + private Function toPoint(final long minuteMin, final double minuteScale, final double valueMin, final double valueScale) { + return entry -> { + final long minuteEpoch = entry.getDate().toEpochSecond() / 60; + final long minuteRelative = minuteEpoch - minuteMin; + final double minuteScaled = minuteRelative * minuteScale; + final int x = (int) Math.round(minuteScaled); + + final double valueRelative = entry.getValue() - valueMin; + final double valueScaled = valueRelative * valueScale; + final int y = (int) Math.round(valueScaled); + + return new Point(x, y); + }; + } + +} diff --git a/src/main/java/de/ph87/data/series/graph/GraphController.java b/src/main/java/de/ph87/data/series/graph/GraphController.java new file mode 100644 index 0000000..6faf57d --- /dev/null +++ b/src/main/java/de/ph87/data/series/graph/GraphController.java @@ -0,0 +1,32 @@ +package de.ph87.data.series.graph; + +import jakarta.servlet.http.*; +import lombok.*; +import lombok.extern.slf4j.*; +import org.springframework.web.bind.annotation.*; + +import javax.imageio.*; +import java.awt.image.*; +import java.io.*; +import java.time.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("Series/Graph") +public class GraphController { + + private final GraphService graphService; + + @GetMapping(path = "{seriesId}/{width}/{height}/{offset}/{duration}", produces = "image/png") + public void graph(@PathVariable final long seriesId, final HttpServletResponse response, @PathVariable final int width, @PathVariable final int height, @PathVariable final String offset, @PathVariable final String duration) throws IOException { + final ZonedDateTime end = ZonedDateTime.now().minus(Duration.parse(offset)); + final ZonedDateTime begin = end.minus(Duration.parse(duration)); + final Graph graph = graphService.getGraph(seriesId, begin, end, width, height, 10); + final BufferedImage image = graph.draw(); + response.setContentType("image/png"); + ImageIO.write(image, "PNG", response.getOutputStream()); + response.getOutputStream().flush(); + } + +} diff --git a/src/main/java/de/ph87/data/series/graph/GraphService.java b/src/main/java/de/ph87/data/series/graph/GraphService.java new file mode 100644 index 0000000..8ce6a3b --- /dev/null +++ b/src/main/java/de/ph87/data/series/graph/GraphService.java @@ -0,0 +1,29 @@ +package de.ph87.data.series.graph; + +import de.ph87.data.series.*; +import de.ph87.data.series.entry.*; +import lombok.*; +import lombok.extern.slf4j.*; +import org.springframework.stereotype.*; +import org.springframework.transaction.annotation.*; + +import java.time.*; +import java.util.*; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class GraphService { + + private final EntryRepository entryRepository; + + private final SeriesService seriesService; + + public Graph getGraph(final long seriesId, @NonNull final ZonedDateTime begin, @NonNull final ZonedDateTime end, final int width, final int height, final int border) { + final SeriesDto series = seriesService.getDtoById(seriesId); + final List entries = entryRepository.findAllBySeriesIdAndDateGreaterThanEqualAndDateLessThanEqual(seriesId, begin, end); + return new Graph(series, entries, begin, end, width, height, border); + } + +} diff --git a/src/main/java/de/ph87/data/unit/Unit.java b/src/main/java/de/ph87/data/unit/Unit.java index f6ecad4..e46ad22 100644 --- a/src/main/java/de/ph87/data/unit/Unit.java +++ b/src/main/java/de/ph87/data/unit/Unit.java @@ -19,6 +19,7 @@ public enum Unit { IAQ_CO2_EQUIVALENT("ppm"), IAQ_VOC_EQUIVALENT("ppm"), SUN_DC("Δ°C"), + UNIT_PERCENT("%"), ; public final String unit;