Add dashboard historian persistence

This commit is contained in:
litoral05
2026-05-20 17:28:58 +01:00
parent 315f7b61f8
commit 5e30160b31
13 changed files with 798 additions and 7 deletions
@@ -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()
);
}
}
@@ -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);
}
}
@@ -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);