Add dashboard historian persistence
This commit is contained in:
+6
-1
@@ -2,6 +2,7 @@ package com.litoralregas.backend.acquisition.scheduler;
|
|||||||
|
|
||||||
import com.litoralregas.backend.acquisition.polling.AcquisitionPollResult;
|
import com.litoralregas.backend.acquisition.polling.AcquisitionPollResult;
|
||||||
import com.litoralregas.backend.acquisition.block.BlockPollingService;
|
import com.litoralregas.backend.acquisition.block.BlockPollingService;
|
||||||
|
import com.litoralregas.backend.websocket.dashboard.DashboardOverviewWebSocketPublisher;
|
||||||
import com.litoralregas.backend.websocket.telemetry.TelemetryWebSocketPublisher;
|
import com.litoralregas.backend.websocket.telemetry.TelemetryWebSocketPublisher;
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
@@ -18,6 +19,7 @@ public class AcquisitionSchedulerService {
|
|||||||
private final AcquisitionSchedulerProperties properties;
|
private final AcquisitionSchedulerProperties properties;
|
||||||
private final TaskScheduler taskScheduler;
|
private final TaskScheduler taskScheduler;
|
||||||
private final TelemetryWebSocketPublisher telemetryWebSocketPublisher;
|
private final TelemetryWebSocketPublisher telemetryWebSocketPublisher;
|
||||||
|
private final DashboardOverviewWebSocketPublisher dashboardOverviewWebSocketPublisher;
|
||||||
|
|
||||||
private final AcquisitionRuntimeStatus runtimeStatus = new AcquisitionRuntimeStatus();
|
private final AcquisitionRuntimeStatus runtimeStatus = new AcquisitionRuntimeStatus();
|
||||||
|
|
||||||
@@ -27,12 +29,14 @@ public class AcquisitionSchedulerService {
|
|||||||
BlockPollingService blockPollingService,
|
BlockPollingService blockPollingService,
|
||||||
AcquisitionSchedulerProperties properties,
|
AcquisitionSchedulerProperties properties,
|
||||||
@Qualifier("acquisitionTaskScheduler") TaskScheduler taskScheduler,
|
@Qualifier("acquisitionTaskScheduler") TaskScheduler taskScheduler,
|
||||||
TelemetryWebSocketPublisher telemetryWebSocketPublisher
|
TelemetryWebSocketPublisher telemetryWebSocketPublisher,
|
||||||
|
DashboardOverviewWebSocketPublisher dashboardOverviewWebSocketPublisher
|
||||||
) {
|
) {
|
||||||
this.blockPollingService = blockPollingService;
|
this.blockPollingService = blockPollingService;
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
this.taskScheduler = taskScheduler;
|
this.taskScheduler = taskScheduler;
|
||||||
this.telemetryWebSocketPublisher = telemetryWebSocketPublisher;
|
this.telemetryWebSocketPublisher = telemetryWebSocketPublisher;
|
||||||
|
this.dashboardOverviewWebSocketPublisher = dashboardOverviewWebSocketPublisher;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
@@ -71,6 +75,7 @@ public class AcquisitionSchedulerService {
|
|||||||
runtimeStatus.setLastFailedReads(result.failedReads());
|
runtimeStatus.setLastFailedReads(result.failedReads());
|
||||||
|
|
||||||
telemetryWebSocketPublisher.publishLatestTelemetry();
|
telemetryWebSocketPublisher.publishLatestTelemetry();
|
||||||
|
dashboardOverviewWebSocketPublisher.publishOverview();
|
||||||
|
|
||||||
} catch (Exception exception) {
|
} catch (Exception exception) {
|
||||||
runtimeStatus.setLastError(exception.getMessage());
|
runtimeStatus.setLastError(exception.getMessage());
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ClimateZoneOverview> 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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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<TelemetrySnapshot> 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<TelemetrySnapshot> 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<TelemetrySnapshot> snapshots) {
|
||||||
|
List<DashboardOverviewResponse.ClimateZoneOverview> 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<TelemetrySnapshot> 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<TelemetrySnapshot> 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<TelemetrySnapshot> snapshots) {
|
||||||
|
List<TelemetrySnapshot> 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<TelemetrySnapshot> 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<TelemetrySnapshot> 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<TelemetrySnapshot> 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<TelemetrySnapshot> snapshots, int modbusAddress, int bitOffset) {
|
||||||
|
Optional<TelemetrySnapshot> 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<TelemetrySnapshot> 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<TelemetrySnapshot> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<HistorianSeriesPoint> 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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, List<HistorianSeriesPoint>> series
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<HistorianSample, Long> {
|
||||||
|
|
||||||
|
List<HistorianSample> findByKeyNameAndSampledAtBetweenOrderBySampledAtAsc(
|
||||||
|
String keyName,
|
||||||
|
Instant from,
|
||||||
|
Instant to
|
||||||
|
);
|
||||||
|
|
||||||
|
List<HistorianSample> findByKeyNameInAndSampledAtBetweenOrderBySampledAtAsc(
|
||||||
|
Collection<String> keyNames,
|
||||||
|
Instant from,
|
||||||
|
Instant to
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.litoralregas.backend.historian;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record HistorianSeriesPoint(
|
||||||
|
Instant timestamp,
|
||||||
|
Double numericValue,
|
||||||
|
Boolean booleanValue,
|
||||||
|
String textValue
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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<HistorianSeriesPoint> 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<String> keyNames,
|
||||||
|
Instant from,
|
||||||
|
Instant to
|
||||||
|
) {
|
||||||
|
List<HistorianSample> samples = historianSampleRepository
|
||||||
|
.findByKeyNameInAndSampledAtBetweenOrderBySampledAtAsc(keyNames, from, to);
|
||||||
|
|
||||||
|
Map<String, List<HistorianSeriesPoint>> 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-6
@@ -1,10 +1,12 @@
|
|||||||
package com.litoralregas.backend.websocket.telemetry;
|
package com.litoralregas.backend.websocket.telemetry;
|
||||||
|
|
||||||
import com.litoralregas.backend.acquisition.telemetry.TelemetryCache;
|
import com.litoralregas.backend.acquisition.telemetry.TelemetryCache;
|
||||||
|
import com.litoralregas.backend.acquisition.telemetry.TelemetrySnapshot;
|
||||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.Collection;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class TelemetryWebSocketPublisher {
|
public class TelemetryWebSocketPublisher {
|
||||||
@@ -23,15 +25,14 @@ public class TelemetryWebSocketPublisher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void publishLatestTelemetry() {
|
public void publishLatestTelemetry() {
|
||||||
|
Collection<TelemetrySnapshot> snapshots = telemetryCache.getAll();
|
||||||
|
|
||||||
TelemetryBroadcastMessage message = new TelemetryBroadcastMessage(
|
TelemetryBroadcastMessage message = new TelemetryBroadcastMessage(
|
||||||
Instant.now(),
|
Instant.now(),
|
||||||
telemetryCache.getAll().size(),
|
snapshots.size(),
|
||||||
telemetryCache.getAll()
|
snapshots
|
||||||
);
|
);
|
||||||
|
|
||||||
messagingTemplate.convertAndSend(
|
messagingTemplate.convertAndSend(DESTINATION, message);
|
||||||
DESTINATION,
|
|
||||||
message
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user