EntryController + Graph

This commit is contained in:
Patrick Haßel 2025-02-18 10:49:28 +01:00
parent 551db4b93d
commit 908134600c
16 changed files with 291 additions and 40 deletions

View File

@ -5,7 +5,7 @@ spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa spring.datasource.username=sa
spring.datasource.password=password 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.host=10.0.0.50
de.ph87.data.message.receive.mqtt.topic=# de.ph87.data.message.receive.mqtt.topic=#

View File

@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.annotation.*; import com.fasterxml.jackson.databind.annotation.*;
import de.ph87.data.message.*; import de.ph87.data.message.*;
import de.ph87.data.series.*; import de.ph87.data.series.*;
import de.ph87.data.series.entry.*;
import de.ph87.data.unit.Value; import de.ph87.data.unit.Value;
import de.ph87.data.unit.*; import de.ph87.data.unit.*;
import lombok.*; import lombok.*;
@ -25,7 +26,7 @@ public class EspHomeHandler implements IMessageHandler {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final SeriesService seriesService; private final EntryService entryService;
@Override @Override
public void handle(@NonNull final Message message) throws Exception { public void handle(@NonNull final Message message) throws Exception {
@ -58,7 +59,7 @@ public class EspHomeHandler implements IMessageHandler {
return; return;
} }
final Value value = new Value(inbound.value, unitFromPayload); 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) { private String propertyReplace(final String property) {

View File

@ -3,6 +3,7 @@ package de.ph87.data.message.handler;
import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.*;
import de.ph87.data.message.*; import de.ph87.data.message.*;
import de.ph87.data.series.*; import de.ph87.data.series.*;
import de.ph87.data.series.entry.*;
import de.ph87.data.unit.Value; import de.ph87.data.unit.Value;
import de.ph87.data.unit.*; import de.ph87.data.unit.*;
import lombok.*; import lombok.*;
@ -21,7 +22,7 @@ public class HeizungHandler implements IMessageHandler {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final SeriesService seriesService; private final EntryService entryService;
@Override @Override
public void handle(@NonNull final Message message) throws Exception { 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 String property = matcher.group("property");
final Inbound inbound = objectMapper.readValue(message.payload, Inbound.class); 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 @Getter

View File

@ -3,6 +3,7 @@ package de.ph87.data.message.handler;
import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.*;
import de.ph87.data.message.*; import de.ph87.data.message.*;
import de.ph87.data.series.*; import de.ph87.data.series.*;
import de.ph87.data.series.entry.*;
import de.ph87.data.unit.Value; import de.ph87.data.unit.Value;
import de.ph87.data.unit.*; import de.ph87.data.unit.*;
import lombok.*; import lombok.*;
@ -18,7 +19,7 @@ public class OpenDTUHandler implements IMessageHandler {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final SeriesService seriesService; private final EntryService entryService;
@Override @Override
public void handle(final @NonNull Message message) throws Exception { public void handle(final @NonNull Message message) throws Exception {
@ -26,11 +27,10 @@ public class OpenDTUHandler implements IMessageHandler {
return; return;
} }
final Inbound inbound = objectMapper.readValue(message.payload, Inbound.class); final Inbound inbound = objectMapper.readValue(message.payload, Inbound.class);
seriesService.receive(new SeriesInbound("electricity/energy/produced", inbound.date, inbound.energy.as(Unit.ENERGY_KWH))); entryService.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/power/produced", inbound.date, inbound.power.as(Unit.POWER_W)));
} }
@Getter @Getter
@ToString @ToString
private static class Inbound { private static class Inbound {

View File

@ -3,6 +3,7 @@ package de.ph87.data.message.handler;
import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.*;
import de.ph87.data.message.*; import de.ph87.data.message.*;
import de.ph87.data.series.*; import de.ph87.data.series.*;
import de.ph87.data.series.entry.*;
import de.ph87.data.unit.Value; import de.ph87.data.unit.Value;
import de.ph87.data.unit.*; import de.ph87.data.unit.*;
import lombok.*; import lombok.*;
@ -18,7 +19,7 @@ public class SimpleJsonHandler implements IMessageHandler {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final SeriesService seriesService; private final EntryService entryService;
@Override @Override
public void handle(@NonNull final Message message) throws Exception { public void handle(@NonNull final Message message) throws Exception {
@ -26,7 +27,7 @@ public class SimpleJsonHandler implements IMessageHandler {
return; return;
} }
final Inbound inbound = objectMapper.readValue(message.payload, Inbound.class); 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 @Getter

View File

@ -3,6 +3,7 @@ package de.ph87.data.message.handler;
import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.*;
import de.ph87.data.message.*; import de.ph87.data.message.*;
import de.ph87.data.series.*; import de.ph87.data.series.*;
import de.ph87.data.series.entry.*;
import de.ph87.data.unit.Value; import de.ph87.data.unit.Value;
import de.ph87.data.unit.*; import de.ph87.data.unit.*;
import lombok.*; import lombok.*;
@ -18,7 +19,7 @@ public class SmartMeterHandler implements IMessageHandler {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final SeriesService seriesService; private final EntryService entryService;
@Override @Override
public void handle(@NonNull final Message message) throws Exception { public void handle(@NonNull final Message message) throws Exception {
@ -26,9 +27,9 @@ public class SmartMeterHandler implements IMessageHandler {
return; return;
} }
final Inbound inbound = objectMapper.readValue(message.payload, Inbound.class); final Inbound inbound = objectMapper.readValue(message.payload, Inbound.class);
seriesService.receive(new SeriesInbound("electricity/energy/purchased", inbound.date, inbound.energyPurchased.as(Unit.ENERGY_KWH))); entryService.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))); entryService.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/power/difference", inbound.date, inbound.powerDifference.as(Unit.POWER_W)));
} }
@Getter @Getter

View File

@ -30,6 +30,10 @@ public class Series {
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private Unit unit; private Unit unit;
@Setter
@Column(nullable = false)
private int decimals = 1;
public Series(@NonNull final String name, @NonNull final Unit unit) { public Series(@NonNull final String name, @NonNull final Unit unit) {
this.name = name; this.name = name;
this.title = name; this.title = name;

View File

@ -1,6 +1,7 @@
package de.ph87.data.series; package de.ph87.data.series;
import de.ph87.data.unit.*; import de.ph87.data.unit.*;
import jakarta.annotation.*;
import lombok.*; import lombok.*;
@Getter @Getter
@ -15,11 +16,21 @@ public class SeriesDto {
public final Unit unit; public final Unit unit;
public final int decimals;
public SeriesDto(@NonNull final Series series) { public SeriesDto(@NonNull final Series series) {
this.id = series.getId(); this.id = series.getId();
this.name = series.getName(); this.name = series.getName();
this.title = series.getTitle(); this.title = series.getTitle();
this.unit = series.getUnit(); 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);
} }
} }

View File

@ -1,7 +1,6 @@
package de.ph87.data.series; package de.ph87.data.series;
import de.ph87.data.*; import de.ph87.data.*;
import de.ph87.data.series.entry.*;
import de.ph87.data.unit.*; import de.ph87.data.unit.*;
import lombok.*; import lombok.*;
import lombok.extern.slf4j.*; import lombok.extern.slf4j.*;
@ -18,22 +17,28 @@ public class SeriesService {
private final SeriesRepository seriesRepository; private final SeriesRepository seriesRepository;
private final EntryService entryService; @SuppressWarnings("unused")
public SeriesDto modify(final long id, @NonNull final Consumer<Series> modifier) {
public SeriesDto modify(@NonNull final long id, @NonNull final Consumer<Series> modifier) {
final Series series = getById(id); final Series series = getById(id);
modifier.accept(series); modifier.accept(series);
return publish(series, Action.CHANGED); 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); 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(); 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) { private SeriesDto publish(@NonNull final Series series, @NonNull final Action action) {
final SeriesDto dto = toDto(series); final SeriesDto dto = toDto(series);
log.info("Series {}: {}", action, series); log.info("Series {}: {}", action, series);
@ -44,12 +49,7 @@ public class SeriesService {
return new SeriesDto(series); return new SeriesDto(series);
} }
public void receive(@NonNull final SeriesInbound measure) throws Unit.NotConvertible { public Series getOrCreate(@NonNull final String name, @NonNull final Unit unit) {
final Series series = getOrCreate(measure.name, measure.value.unit);
entryService.write(series, measure);
}
private Series getOrCreate(@NonNull final String name, @NonNull final Unit unit) {
return seriesRepository return seriesRepository
.findByName(name) .findByName(name)
.orElseGet(() -> { .orElseGet(() -> {

View File

@ -11,4 +11,6 @@ public interface EntryRepository extends ListCrudRepository<Entry, Long> {
Optional<Entry> findBySeriesAndDate(@NonNull Series series, @NonNull ZonedDateTime truncated); Optional<Entry> findBySeriesAndDate(@NonNull Series series, @NonNull ZonedDateTime truncated);
List<Entry> findAllBySeriesIdAndDateGreaterThanEqualAndDateLessThanEqual(long id, @NonNull ZonedDateTime begin, @NonNull ZonedDateTime end);
} }

View File

@ -18,12 +18,19 @@ public class EntryService {
private final EntryRepository entryRepository; private final EntryRepository entryRepository;
private final SeriesService seriesService;
public void write(@NonNull final Series series, @NonNull final SeriesInbound measure) throws Unit.NotConvertible { 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()) { if (entryRepository.findBySeriesAndDate(series, truncated).isEmpty()) {
final Entry created = entryRepository.save(new Entry(series, truncated, measure.value)); final Entry created = entryRepository.save(new Entry(series, truncated, measure.value));
log.debug("Created: {}", created); 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);
}
} }

View File

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

View File

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

View File

@ -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<Entry> entries = entryRepository.findAllBySeriesIdAndDateGreaterThanEqualAndDateLessThanEqual(seriesId, begin, end);
return new Graph(series, entries, begin, end, width, height, border);
}
}

View File

@ -19,6 +19,7 @@ public enum Unit {
IAQ_CO2_EQUIVALENT("ppm"), IAQ_CO2_EQUIVALENT("ppm"),
IAQ_VOC_EQUIVALENT("ppm"), IAQ_VOC_EQUIVALENT("ppm"),
SUN_DC("Δ°C"), SUN_DC("Δ°C"),
UNIT_PERCENT("%"),
; ;
public final String unit; public final String unit;