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