From 5e30160b31318f07ed5db54cb2b78ee51ce1449c Mon Sep 17 00:00:00 2001 From: litoral05 Date: Wed, 20 May 2026 17:28:58 +0100 Subject: [PATCH] Add dashboard historian persistence --- .../AcquisitionSchedulerService.java | 7 +- .../dashboard/DashboardController.java | 19 ++ .../dashboard/DashboardOverviewResponse.java | 50 +++ .../dashboard/DashboardOverviewService.java | 286 ++++++++++++++++++ .../historian/HistorianController.java | 49 +++ .../historian/HistorianDashboardResponse.java | 12 + .../backend/historian/HistorianSample.java | 125 ++++++++ .../historian/HistorianSampleRepository.java | 22 ++ .../historian/HistorianSeriesPoint.java | 11 + .../backend/historian/HistorianService.java | 153 ++++++++++ .../DashboardOverviewWebSocketPublisher.java | 37 +++ .../TelemetryWebSocketPublisher.java | 13 +- .../migration/V4__create_historian_sample.sql | 21 ++ 13 files changed, 798 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/litoralregas/backend/dashboard/DashboardController.java create mode 100644 src/main/java/com/litoralregas/backend/dashboard/DashboardOverviewResponse.java create mode 100644 src/main/java/com/litoralregas/backend/dashboard/DashboardOverviewService.java create mode 100644 src/main/java/com/litoralregas/backend/historian/HistorianController.java create mode 100644 src/main/java/com/litoralregas/backend/historian/HistorianDashboardResponse.java create mode 100644 src/main/java/com/litoralregas/backend/historian/HistorianSample.java create mode 100644 src/main/java/com/litoralregas/backend/historian/HistorianSampleRepository.java create mode 100644 src/main/java/com/litoralregas/backend/historian/HistorianSeriesPoint.java create mode 100644 src/main/java/com/litoralregas/backend/historian/HistorianService.java create mode 100644 src/main/java/com/litoralregas/backend/websocket/dashboard/DashboardOverviewWebSocketPublisher.java create mode 100644 src/main/resources/db/migration/V4__create_historian_sample.sql diff --git a/src/main/java/com/litoralregas/backend/acquisition/scheduler/AcquisitionSchedulerService.java b/src/main/java/com/litoralregas/backend/acquisition/scheduler/AcquisitionSchedulerService.java index e4cf805..25becb6 100644 --- a/src/main/java/com/litoralregas/backend/acquisition/scheduler/AcquisitionSchedulerService.java +++ b/src/main/java/com/litoralregas/backend/acquisition/scheduler/AcquisitionSchedulerService.java @@ -2,6 +2,7 @@ package com.litoralregas.backend.acquisition.scheduler; import com.litoralregas.backend.acquisition.polling.AcquisitionPollResult; import com.litoralregas.backend.acquisition.block.BlockPollingService; +import com.litoralregas.backend.websocket.dashboard.DashboardOverviewWebSocketPublisher; import com.litoralregas.backend.websocket.telemetry.TelemetryWebSocketPublisher; import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Qualifier; @@ -18,6 +19,7 @@ public class AcquisitionSchedulerService { private final AcquisitionSchedulerProperties properties; private final TaskScheduler taskScheduler; private final TelemetryWebSocketPublisher telemetryWebSocketPublisher; + private final DashboardOverviewWebSocketPublisher dashboardOverviewWebSocketPublisher; private final AcquisitionRuntimeStatus runtimeStatus = new AcquisitionRuntimeStatus(); @@ -27,12 +29,14 @@ public class AcquisitionSchedulerService { BlockPollingService blockPollingService, AcquisitionSchedulerProperties properties, @Qualifier("acquisitionTaskScheduler") TaskScheduler taskScheduler, - TelemetryWebSocketPublisher telemetryWebSocketPublisher + TelemetryWebSocketPublisher telemetryWebSocketPublisher, + DashboardOverviewWebSocketPublisher dashboardOverviewWebSocketPublisher ) { this.blockPollingService = blockPollingService; this.properties = properties; this.taskScheduler = taskScheduler; this.telemetryWebSocketPublisher = telemetryWebSocketPublisher; + this.dashboardOverviewWebSocketPublisher = dashboardOverviewWebSocketPublisher; } @PostConstruct @@ -71,6 +75,7 @@ public class AcquisitionSchedulerService { runtimeStatus.setLastFailedReads(result.failedReads()); telemetryWebSocketPublisher.publishLatestTelemetry(); + dashboardOverviewWebSocketPublisher.publishOverview(); } catch (Exception exception) { runtimeStatus.setLastError(exception.getMessage()); diff --git a/src/main/java/com/litoralregas/backend/dashboard/DashboardController.java b/src/main/java/com/litoralregas/backend/dashboard/DashboardController.java new file mode 100644 index 0000000..637e432 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/dashboard/DashboardController.java @@ -0,0 +1,19 @@ +package com.litoralregas.backend.dashboard; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class DashboardController { + + private final DashboardOverviewService dashboardOverviewService; + + public DashboardController(DashboardOverviewService dashboardOverviewService) { + this.dashboardOverviewService = dashboardOverviewService; + } + + @GetMapping("/api/dashboard/overview") + public DashboardOverviewResponse getOverview() { + return dashboardOverviewService.getOverview(); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/dashboard/DashboardOverviewResponse.java b/src/main/java/com/litoralregas/backend/dashboard/DashboardOverviewResponse.java new file mode 100644 index 0000000..89ce913 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/dashboard/DashboardOverviewResponse.java @@ -0,0 +1,50 @@ +package com.litoralregas.backend.dashboard; + +import java.time.Instant; +import java.util.List; + +public record DashboardOverviewResponse( + Instant timestamp, + MeteoOverview meteo, + ClimateOverview climate, + IrrigationOverview irrigation, + LightingOverview lighting +) { + public record MeteoOverview( + Double exteriorTemperature, + Double exteriorHumidity, + Double radiation, + Double windSpeed, + Double windDirection, + Boolean raining + ) {} + + public record ClimateOverview( + Integer zoneCount, + List zones + ) {} + + public record ClimateZoneOverview( + Integer zoneNumber, + Double temperature, + Double humidity, + Double co2, + Boolean fansOn, + Boolean extractorsOn, + Double zenitalLeftPercent, + Double zenitalRightPercent, + Double lateralLeftPercent, + Double lateralRightPercent + ) {} + + public record IrrigationOverview( + Integer controllerCount, + Integer activeValveCount, + Integer activePumpCount + ) {} + + public record LightingOverview( + Integer sectorCount, + Integer activeSectorCount + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/dashboard/DashboardOverviewService.java b/src/main/java/com/litoralregas/backend/dashboard/DashboardOverviewService.java new file mode 100644 index 0000000..4c0e909 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/dashboard/DashboardOverviewService.java @@ -0,0 +1,286 @@ +package com.litoralregas.backend.dashboard; + +import com.litoralregas.backend.acquisition.telemetry.TelemetryCache; +import com.litoralregas.backend.acquisition.telemetry.TelemetrySnapshot; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Service +public class DashboardOverviewService { + + private final TelemetryCache telemetryCache; + + public DashboardOverviewService(TelemetryCache telemetryCache) { + this.telemetryCache = telemetryCache; + } + + public DashboardOverviewResponse getOverview() { + Collection snapshots = telemetryCache.getAll(); + System.out.println("DashboardOverview snapshot count = " + snapshots.size()); + + snapshots.stream() + .limit(20) + .forEach(snapshot -> + System.out.println( + snapshot.sensorId() + " | " + + snapshot.name() + " | " + + snapshot.modbusAddress() + " | " + + snapshot.value() + ) + ); + return new DashboardOverviewResponse( + Instant.now(), + buildMeteo(snapshots), + buildClimate(snapshots), + buildIrrigation(snapshots), + buildLighting(snapshots) + ); + } + + private DashboardOverviewResponse.MeteoOverview buildMeteo( + Collection snapshots + ) { + return new DashboardOverviewResponse.MeteoOverview( + numberByNameContains(snapshots, "Temperatura exterior"), + numberByNameContains(snapshots, "Humidade exterior"), + numberByNameContains(snapshots, "Radiacao"), + numberByNameContains(snapshots, "Velocidade Vento"), + numberByNameContains(snapshots, "Direcao Vento"), + booleanByNameContains(snapshots, "Chuva") + ); + } + + private DashboardOverviewResponse.ClimateOverview buildClimate(Collection snapshots) { + List zones = + java.util.stream.IntStream.rangeClosed(1, 6) + .mapToObj(zone -> buildClimateZone(snapshots, zone)) + .toList(); + + return new DashboardOverviewResponse.ClimateOverview( + zones.size(), + zones + ); + } + + private DashboardOverviewResponse.ClimateZoneOverview buildClimateZone( + Collection snapshots, + int zone + ) { + int base = 100 + ((zone - 1) * 40); + + return new DashboardOverviewResponse.ClimateZoneOverview( + zone, + + validTemperature( + numberByAddressAndNameContains( + snapshots, + base, + "Temperatura estufa " + zone + ) + ), + + validHumidity( + numberByAddressAndNameContains( + snapshots, + base + 1, + "Humidade estufa " + zone + ) + ), + + validCo2( + numberByAddressAndNameContains( + snapshots, + base + 12, + "CO2 estufa " + zone + ) + ), + + booleanByAddressAndBit(snapshots, base + 15, 0), + booleanByAddressAndBit(snapshots, base + 15, 1), + + validPercent( + numberByAddressAndNameContains( + snapshots, + base + 16, + "Zenital E E" + zone + ) + ), + + validPercent( + numberByAddressAndNameContains( + snapshots, + base + 17, + "Zenital D E" + zone + ) + ), + + validPercent( + numberByAddressAndNameContains( + snapshots, + base + 18, + "Lateral E E" + zone + ) + ), + + validPercent( + numberByAddressAndNameContains( + snapshots, + base + 19, + "Lateral D E" + zone + ) + ) + ); + } + + private DashboardOverviewResponse.IrrigationOverview buildIrrigation(Collection snapshots) { + long activeValves = snapshots.stream() + .filter(snapshot -> snapshot.name() != null && snapshot.name().matches("V\\d+ C\\d+")) + .filter(snapshot -> Boolean.TRUE.equals(snapshot.value())) + .count(); + + long activePumps = snapshots.stream() + .filter(snapshot -> snapshot.name() != null && snapshot.name().toLowerCase().contains("bomba")) + .filter(snapshot -> Boolean.TRUE.equals(snapshot.value())) + .count(); + + return new DashboardOverviewResponse.IrrigationOverview( + 3, + Math.toIntExact(activeValves), + Math.toIntExact(activePumps) + ); + } + + private DashboardOverviewResponse.LightingOverview buildLighting(Collection snapshots) { + List lightingSectors = snapshots.stream() + .filter(snapshot -> snapshot.name() != null && snapshot.name().startsWith("IL Setor")) + .toList(); + + long activeSectors = lightingSectors.stream() + .filter(snapshot -> Boolean.TRUE.equals(snapshot.value())) + .count(); + + return new DashboardOverviewResponse.LightingOverview( + lightingSectors.size(), + Math.toIntExact(activeSectors) + ); + } + + private Double numberByAddress(Collection snapshots, int modbusAddress) { + return snapshots.stream() + .filter(snapshot -> snapshot.modbusAddress() != null) + .filter(snapshot -> snapshot.modbusAddress() == modbusAddress) + .map(TelemetrySnapshot::value) + .filter(Number.class::isInstance) + .map(Number.class::cast) + .map(Number::doubleValue) + .findFirst() + .orElse(null); + } + + private Double numberByAddressAndNameContains( + Collection snapshots, + int modbusAddress, + String namePart + ) { + return snapshots.stream() + .filter(snapshot -> snapshot.modbusAddress() != null) + .filter(snapshot -> snapshot.modbusAddress() == modbusAddress) + .filter(snapshot -> snapshot.name() != null) + .filter(snapshot -> snapshot.name().toLowerCase().contains(namePart.toLowerCase())) + .map(TelemetrySnapshot::value) + .filter(Number.class::isInstance) + .map(Number.class::cast) + .map(Number::doubleValue) + .findFirst() + .orElse(null); + } + + private Boolean booleanByAddress(Collection snapshots, int modbusAddress) { + return snapshots.stream() + .filter(snapshot -> snapshot.modbusAddress() != null) + .filter(snapshot -> snapshot.modbusAddress() == modbusAddress) + .map(TelemetrySnapshot::value) + .findFirst() + .map(value -> { + if (value instanceof Boolean bool) return bool; + if (value instanceof Number number) return number.doubleValue() > 0; + return null; + }) + .orElse(null); + } + + private Boolean booleanByAddressAndBit(Collection snapshots, int modbusAddress, int bitOffset) { + Optional snapshot = snapshots.stream() + .filter(item -> item.modbusAddress() != null && item.modbusAddress() == modbusAddress) + .filter(item -> item.bitOffset() != null && item.bitOffset() == bitOffset) + .findFirst(); + + return snapshot + .map(TelemetrySnapshot::value) + .filter(Boolean.class::isInstance) + .map(Boolean.class::cast) + .orElse(null); + } + + private Double validTemperature(Double value) { + if (value == null || value <= 0 || value > 80) return null; + return value; + } + + private Double validHumidity(Double value) { + if (value == null || value <= 0 || value > 100) return null; + return value; + } + + private Double validCo2(Double value) { + if (value == null || value < 0 || value > 5000) return null; + return value; + } + + private Double validPercent(Double value) { + if (value == null || value < 0 || value > 100) return null; + return value; + } + + private Double numberByNameContains( + Collection snapshots, + String namePart + ) { + return snapshots.stream() + .filter(snapshot -> snapshot.name() != null) + .filter(snapshot -> + snapshot.name().toLowerCase() + .contains(namePart.toLowerCase()) + ) + .map(TelemetrySnapshot::value) + .filter(Number.class::isInstance) + .map(Number.class::cast) + .map(Number::doubleValue) + .findFirst() + .orElse(null); + } + + private Boolean booleanByNameContains( + Collection snapshots, + String namePart + ) { + return snapshots.stream() + .filter(snapshot -> snapshot.name() != null) + .filter(snapshot -> + snapshot.name().toLowerCase() + .contains(namePart.toLowerCase()) + ) + .map(TelemetrySnapshot::value) + .findFirst() + .map(value -> { + if (value instanceof Boolean bool) return bool; + if (value instanceof Number number) return number.doubleValue() > 0; + return null; + }) + .orElse(null); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/historian/HistorianController.java b/src/main/java/com/litoralregas/backend/historian/HistorianController.java new file mode 100644 index 0000000..63e4343 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/historian/HistorianController.java @@ -0,0 +1,49 @@ +package com.litoralregas.backend.historian; + +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; +import java.util.List; + +@RestController +public class HistorianController { + + private final HistorianService historianService; + + public HistorianController(HistorianService historianService) { + this.historianService = historianService; + } + + @GetMapping("/api/historian/series") + public List getSeries( + @RequestParam String key, + + @RequestParam + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + Instant from, + + @RequestParam + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + Instant to + ) { + return historianService.getSeries(key, from, to); + } + + @GetMapping("/api/historian/dashboard") + public HistorianDashboardResponse getDashboardHistory( + @RequestParam List keys, + + @RequestParam + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + Instant from, + + @RequestParam + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + Instant to + ) { + return historianService.getDashboardHistory(keys, from, to); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/historian/HistorianDashboardResponse.java b/src/main/java/com/litoralregas/backend/historian/HistorianDashboardResponse.java new file mode 100644 index 0000000..9cf68ff --- /dev/null +++ b/src/main/java/com/litoralregas/backend/historian/HistorianDashboardResponse.java @@ -0,0 +1,12 @@ +package com.litoralregas.backend.historian; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +public record HistorianDashboardResponse( + Instant from, + Instant to, + Map> series +) { +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/historian/HistorianSample.java b/src/main/java/com/litoralregas/backend/historian/HistorianSample.java new file mode 100644 index 0000000..ad3a7ba --- /dev/null +++ b/src/main/java/com/litoralregas/backend/historian/HistorianSample.java @@ -0,0 +1,125 @@ +package com.litoralregas.backend.historian; + +import jakarta.persistence.*; + +import java.time.Instant; + +@Entity +@Table( + name = "historian_sample", + indexes = { + @Index( + name = "idx_historian_sample_key_sampled_at", + columnList = "key_name, sampled_at" + ), + @Index( + name = "idx_historian_sample_sampled_at", + columnList = "sampled_at" + ) + } +) +public class HistorianSample { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(name = "sampled_at", nullable = false) + private Instant sampledAt; + + @Column(name = "key_name", nullable = false) + private String keyName; + + @Column(name = "numeric_value") + private Double numericValue; + + @Column(name = "boolean_value") + private Boolean booleanValue; + + @Column(name = "text_value") + private String textValue; + + @Column(name = "unit") + private String unit; + + @Column(name = "source", nullable = false) + private String source; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + public void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + + if (sampledAt == null) { + sampledAt = Instant.now(); + } + } + + public Integer getId() { + return id; + } + + public Instant getSampledAt() { + return sampledAt; + } + + public void setSampledAt(Instant sampledAt) { + this.sampledAt = sampledAt; + } + + public String getKeyName() { + return keyName; + } + + public void setKeyName(String keyName) { + this.keyName = keyName; + } + + public Double getNumericValue() { + return numericValue; + } + + public void setNumericValue(Double numericValue) { + this.numericValue = numericValue; + } + + public Boolean getBooleanValue() { + return booleanValue; + } + + public void setBooleanValue(Boolean booleanValue) { + this.booleanValue = booleanValue; + } + + public String getTextValue() { + return textValue; + } + + public void setTextValue(String textValue) { + this.textValue = textValue; + } + + public String getUnit() { + return unit; + } + + public void setUnit(String unit) { + this.unit = unit; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public Instant getCreatedAt() { + return createdAt; + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/historian/HistorianSampleRepository.java b/src/main/java/com/litoralregas/backend/historian/HistorianSampleRepository.java new file mode 100644 index 0000000..8271982 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/historian/HistorianSampleRepository.java @@ -0,0 +1,22 @@ +package com.litoralregas.backend.historian; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.Instant; +import java.util.Collection; +import java.util.List; + +public interface HistorianSampleRepository extends JpaRepository { + + List findByKeyNameAndSampledAtBetweenOrderBySampledAtAsc( + String keyName, + Instant from, + Instant to + ); + + List findByKeyNameInAndSampledAtBetweenOrderBySampledAtAsc( + Collection keyNames, + Instant from, + Instant to + ); +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/historian/HistorianSeriesPoint.java b/src/main/java/com/litoralregas/backend/historian/HistorianSeriesPoint.java new file mode 100644 index 0000000..8e04997 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/historian/HistorianSeriesPoint.java @@ -0,0 +1,11 @@ +package com.litoralregas.backend.historian; + +import java.time.Instant; + +public record HistorianSeriesPoint( + Instant timestamp, + Double numericValue, + Boolean booleanValue, + String textValue +) { +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/historian/HistorianService.java b/src/main/java/com/litoralregas/backend/historian/HistorianService.java new file mode 100644 index 0000000..17faf1e --- /dev/null +++ b/src/main/java/com/litoralregas/backend/historian/HistorianService.java @@ -0,0 +1,153 @@ +package com.litoralregas.backend.historian; + +import com.litoralregas.backend.dashboard.DashboardOverviewResponse; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class HistorianService { + + private static final String SOURCE_DASHBOARD_OVERVIEW = "DASHBOARD_OVERVIEW"; + + private final HistorianSampleRepository historianSampleRepository; + + public HistorianService(HistorianSampleRepository historianSampleRepository) { + this.historianSampleRepository = historianSampleRepository; + } + + @Transactional + public void recordDashboardOverview(DashboardOverviewResponse overview) { + System.out.println("Historian recording overview at " + overview.timestamp()); + Instant sampledAt = overview.timestamp(); + + System.out.println("Meteo object = " + overview.meteo()); + + if (overview.climate() != null && !overview.climate().zones().isEmpty()) { + System.out.println( + "Zone 1 temp = " + + overview.climate().zones().get(0).temperature() + ); + } + recordNumber(sampledAt, "meteo.exterior_temperature", overview.meteo().exteriorTemperature(), "°C"); + recordNumber(sampledAt, "meteo.exterior_humidity", overview.meteo().exteriorHumidity(), "%"); + recordNumber(sampledAt, "meteo.radiation", overview.meteo().radiation(), "W/m²"); + recordNumber(sampledAt, "meteo.wind_speed", overview.meteo().windSpeed(), "Km/h"); + recordNumber(sampledAt, "meteo.wind_direction", overview.meteo().windDirection(), "°"); + recordBoolean(sampledAt, "meteo.raining", overview.meteo().raining()); + + if (overview.climate() != null && overview.climate().zones() != null) { + for (DashboardOverviewResponse.ClimateZoneOverview zone : overview.climate().zones()) { + String prefix = "climate.zone_" + zone.zoneNumber(); + + recordNumber(sampledAt, prefix + ".temperature", zone.temperature(), "°C"); + recordNumber(sampledAt, prefix + ".humidity", zone.humidity(), "%"); + recordNumber(sampledAt, prefix + ".co2", zone.co2(), "ppm"); + + recordBoolean(sampledAt, prefix + ".fans_on", zone.fansOn()); + recordBoolean(sampledAt, prefix + ".extractors_on", zone.extractorsOn()); + + recordNumber(sampledAt, prefix + ".zenital_left_percent", zone.zenitalLeftPercent(), "%"); + recordNumber(sampledAt, prefix + ".zenital_right_percent", zone.zenitalRightPercent(), "%"); + recordNumber(sampledAt, prefix + ".lateral_left_percent", zone.lateralLeftPercent(), "%"); + recordNumber(sampledAt, prefix + ".lateral_right_percent", zone.lateralRightPercent(), "%"); + } + } + + if (overview.irrigation() != null) { + recordNumber( + sampledAt, + "irrigation.active_valve_count", + overview.irrigation().activeValveCount(), + "valves" + ); + + recordNumber( + sampledAt, + "irrigation.active_pump_count", + overview.irrigation().activePumpCount(), + "pumps" + ); + } + + if (overview.lighting() != null) { + recordNumber( + sampledAt, + "lighting.active_sector_count", + overview.lighting().activeSectorCount(), + "sectors" + ); + } + } + + @Transactional(readOnly = true) + public List getSeries(String keyName, Instant from, Instant to) { + return historianSampleRepository + .findByKeyNameAndSampledAtBetweenOrderBySampledAtAsc(keyName, from, to) + .stream() + .map(this::toSeriesPoint) + .toList(); + } + + @Transactional(readOnly = true) + public HistorianDashboardResponse getDashboardHistory( + List keyNames, + Instant from, + Instant to + ) { + List samples = historianSampleRepository + .findByKeyNameInAndSampledAtBetweenOrderBySampledAtAsc(keyNames, from, to); + + Map> grouped = new LinkedHashMap<>(); + + for (String keyName : keyNames) { + grouped.put( + keyName, + samples.stream() + .filter(sample -> sample.getKeyName().equals(keyName)) + .map(this::toSeriesPoint) + .toList() + ); + } + + return new HistorianDashboardResponse(from, to, grouped); + } + + private void recordNumber(Instant sampledAt, String keyName, Number value, String unit) { + if (value == null) return; + + HistorianSample sample = new HistorianSample(); + sample.setSampledAt(sampledAt); + sample.setKeyName(keyName); + sample.setNumericValue(value.doubleValue()); + sample.setUnit(unit); + sample.setSource(SOURCE_DASHBOARD_OVERVIEW); + System.out.println("Saving historian sample: " + keyName + " = " + value); + historianSampleRepository.save(sample); + } + + private void recordBoolean(Instant sampledAt, String keyName, Boolean value) { + if (value == null) return; + + HistorianSample sample = new HistorianSample(); + sample.setSampledAt(sampledAt); + sample.setKeyName(keyName); + sample.setBooleanValue(value); + sample.setSource(SOURCE_DASHBOARD_OVERVIEW); + + historianSampleRepository.save(sample); + } + + private HistorianSeriesPoint toSeriesPoint(HistorianSample sample) { + return new HistorianSeriesPoint( + sample.getSampledAt(), + sample.getNumericValue(), + sample.getBooleanValue(), + sample.getTextValue() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/websocket/dashboard/DashboardOverviewWebSocketPublisher.java b/src/main/java/com/litoralregas/backend/websocket/dashboard/DashboardOverviewWebSocketPublisher.java new file mode 100644 index 0000000..37a0422 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/websocket/dashboard/DashboardOverviewWebSocketPublisher.java @@ -0,0 +1,37 @@ +package com.litoralregas.backend.websocket.dashboard; + +import com.litoralregas.backend.dashboard.DashboardOverviewResponse; +import com.litoralregas.backend.dashboard.DashboardOverviewService; +import com.litoralregas.backend.historian.HistorianService; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +@Service +public class DashboardOverviewWebSocketPublisher { + + private static final String DESTINATION = "/topic/dashboard/overview"; + + private final SimpMessagingTemplate messagingTemplate; + private final DashboardOverviewService dashboardOverviewService; + private final HistorianService historianService; + + public DashboardOverviewWebSocketPublisher( + SimpMessagingTemplate messagingTemplate, + DashboardOverviewService dashboardOverviewService, + HistorianService historianService + ) { + this.messagingTemplate = messagingTemplate; + this.dashboardOverviewService = dashboardOverviewService; + this.historianService = historianService; + } + + public void publishOverview() { + DashboardOverviewResponse overview = dashboardOverviewService.getOverview(); + + System.out.println("Publishing dashboard overview at " + overview.timestamp()); + + historianService.recordDashboardOverview(overview); + + messagingTemplate.convertAndSend(DESTINATION, overview); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/websocket/telemetry/TelemetryWebSocketPublisher.java b/src/main/java/com/litoralregas/backend/websocket/telemetry/TelemetryWebSocketPublisher.java index 5028410..2454810 100644 --- a/src/main/java/com/litoralregas/backend/websocket/telemetry/TelemetryWebSocketPublisher.java +++ b/src/main/java/com/litoralregas/backend/websocket/telemetry/TelemetryWebSocketPublisher.java @@ -1,10 +1,12 @@ package com.litoralregas.backend.websocket.telemetry; import com.litoralregas.backend.acquisition.telemetry.TelemetryCache; +import com.litoralregas.backend.acquisition.telemetry.TelemetrySnapshot; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import java.time.Instant; +import java.util.Collection; @Service public class TelemetryWebSocketPublisher { @@ -23,15 +25,14 @@ public class TelemetryWebSocketPublisher { } public void publishLatestTelemetry() { + Collection snapshots = telemetryCache.getAll(); + TelemetryBroadcastMessage message = new TelemetryBroadcastMessage( Instant.now(), - telemetryCache.getAll().size(), - telemetryCache.getAll() + snapshots.size(), + snapshots ); - messagingTemplate.convertAndSend( - DESTINATION, - message - ); + messagingTemplate.convertAndSend(DESTINATION, message); } } \ No newline at end of file diff --git a/src/main/resources/db/migration/V4__create_historian_sample.sql b/src/main/resources/db/migration/V4__create_historian_sample.sql new file mode 100644 index 0000000..f6eb345 --- /dev/null +++ b/src/main/resources/db/migration/V4__create_historian_sample.sql @@ -0,0 +1,21 @@ +CREATE TABLE historian_sample ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + sampled_at TIMESTAMP NOT NULL, + key_name VARCHAR(160) NOT NULL, + + numeric_value REAL, + boolean_value BOOLEAN, + text_value VARCHAR(255), + + unit VARCHAR(32), + source VARCHAR(50) NOT NULL, + + created_at TIMESTAMP NOT NULL +); + +CREATE INDEX idx_historian_sample_key_sampled_at + ON historian_sample (key_name, sampled_at); + +CREATE INDEX idx_historian_sample_sampled_at + ON historian_sample (sampled_at); \ No newline at end of file