Refactor telemetry registry to use sensor definitions .json and historian identity

This commit is contained in:
litoral05
2026-05-27 08:39:51 +01:00
parent 0a33f42502
commit 727278d644
26 changed files with 25802 additions and 2145 deletions
@@ -76,7 +76,9 @@ public class BlockPollingService {
telemetryCache.put(new TelemetrySnapshot( telemetryCache.put(new TelemetrySnapshot(
sensor.getId(), sensor.getId(),
sensor.getKey(),
sensor.getName(), sensor.getName(),
sensor.getCategory(),
sensor.getModbusAddress(), sensor.getModbusAddress(),
sensor.getBitOffset(), sensor.getBitOffset(),
rawValue, rawValue,
@@ -5,6 +5,9 @@ import com.litoralregas.backend.acquisition.block.BlockPollingService;
import com.litoralregas.backend.dashboard.DashboardOverviewResponse; import com.litoralregas.backend.dashboard.DashboardOverviewResponse;
import com.litoralregas.backend.dashboard.DashboardOverviewService; import com.litoralregas.backend.dashboard.DashboardOverviewService;
import com.litoralregas.backend.historian.HistorianService; import com.litoralregas.backend.historian.HistorianService;
import com.litoralregas.backend.modules.climate.ClimateModuleResponse;
import com.litoralregas.backend.modules.climate.ClimateModuleService;
import com.litoralregas.backend.modules.climate.websocket.ClimateModuleWebSocketPublisher;
import com.litoralregas.backend.modules.meteo.MeteoModuleResponse; import com.litoralregas.backend.modules.meteo.MeteoModuleResponse;
import com.litoralregas.backend.modules.meteo.websocket.MeteoModuleWebSocketPublisher; import com.litoralregas.backend.modules.meteo.websocket.MeteoModuleWebSocketPublisher;
import com.litoralregas.backend.websocket.dashboard.DashboardOverviewWebSocketPublisher; import com.litoralregas.backend.websocket.dashboard.DashboardOverviewWebSocketPublisher;
@@ -35,6 +38,9 @@ public class AcquisitionSchedulerService {
private final AtomicBoolean polling = new AtomicBoolean(false); private final AtomicBoolean polling = new AtomicBoolean(false);
private final ClimateModuleWebSocketPublisher climateModuleWebSocketPublisher;
private final ClimateModuleService climateModuleService;
public AcquisitionSchedulerService( public AcquisitionSchedulerService(
BlockPollingService blockPollingService, BlockPollingService blockPollingService,
AcquisitionSchedulerProperties properties, AcquisitionSchedulerProperties properties,
@@ -44,7 +50,9 @@ public class AcquisitionSchedulerService {
MeteoModuleWebSocketPublisher meteoModuleWebSocketPublisher, MeteoModuleWebSocketPublisher meteoModuleWebSocketPublisher,
HistorianService historianService, HistorianService historianService,
DashboardOverviewService dashboardOverviewService, DashboardOverviewService dashboardOverviewService,
MeteoModuleService meteoModuleService MeteoModuleService meteoModuleService,
ClimateModuleWebSocketPublisher climateModuleWebSocketPublisher,
ClimateModuleService climateModuleService
) { ) {
this.blockPollingService = blockPollingService; this.blockPollingService = blockPollingService;
this.properties = properties; this.properties = properties;
@@ -55,6 +63,8 @@ public class AcquisitionSchedulerService {
this.historianService = historianService; this.historianService = historianService;
this.dashboardOverviewService = dashboardOverviewService; this.dashboardOverviewService = dashboardOverviewService;
this.meteoModuleService = meteoModuleService; this.meteoModuleService = meteoModuleService;
this.climateModuleWebSocketPublisher = climateModuleWebSocketPublisher;
this.climateModuleService = climateModuleService;
} }
@PostConstruct @PostConstruct
@@ -94,14 +104,31 @@ public class AcquisitionSchedulerService {
telemetryWebSocketPublisher.publishLatestTelemetry(); telemetryWebSocketPublisher.publishLatestTelemetry();
DashboardOverviewResponse overview = dashboardOverviewService.getOverview(); DashboardOverviewResponse overview =
historianService.recordDashboardOverview(overview); dashboardOverviewService.getOverview();
dashboardOverviewWebSocketPublisher.publishOverview(overview); dashboardOverviewWebSocketPublisher.publishOverview(overview);
MeteoModuleResponse meteo = meteoModuleService.getLatest(); MeteoModuleResponse meteo =
historianService.recordModuleSensors("meteo", meteo.sensors(), meteo.timestamp()); meteoModuleService.getLatest();
historianService.recordModuleSensors(
meteo.sensors(),
meteo.timestamp()
);
meteoModuleWebSocketPublisher.publishLatest(meteo); meteoModuleWebSocketPublisher.publishLatest(meteo);
ClimateModuleResponse climate =
climateModuleService.getLatest();
historianService.recordModuleSensors(
climate.sensors(),
climate.timestamp()
);
climateModuleWebSocketPublisher.publishLatest(climate);
} catch (Exception exception) { } catch (Exception exception) {
runtimeStatus.setLastError(exception.getMessage()); runtimeStatus.setLastError(exception.getMessage());
@@ -1,7 +1,5 @@
package com.litoralregas.backend.acquisition.telemetry; package com.litoralregas.backend.acquisition.telemetry;
import com.litoralregas.backend.acquisition.telemetry.TelemetryCache;
import com.litoralregas.backend.acquisition.telemetry.TelemetrySnapshot;
import com.litoralregas.backend.modbus.LrModbusClient; import com.litoralregas.backend.modbus.LrModbusClient;
import com.litoralregas.backend.modbus.ModbusReadResult; import com.litoralregas.backend.modbus.ModbusReadResult;
import com.litoralregas.backend.modbus.ModbusUnit; import com.litoralregas.backend.modbus.ModbusUnit;
@@ -32,11 +30,19 @@ public class SensorTelemetryReader {
} }
public TelemetrySnapshot readSensor(Integer sensorId) { public TelemetrySnapshot readSensor(Integer sensorId) {
SensorDefinition sensorDefinition = sensorDefinitionRepository.findById(sensorId)
.orElseThrow(() -> new EntityNotFoundException("Sensor definition not found: " + sensorId)); SensorDefinition sensorDefinition =
sensorDefinitionRepository.findById(sensorId)
.orElseThrow(() ->
new EntityNotFoundException(
"Sensor definition not found: " + sensorId
)
);
if (sensorDefinition.getSourceType() != SensorSourceType.MODBUS) { if (sensorDefinition.getSourceType() != SensorSourceType.MODBUS) {
throw new IllegalArgumentException("Only MODBUS sensors can be read directly."); throw new IllegalArgumentException(
"Only MODBUS sensors can be read directly."
);
} }
ModbusReadResult result = modbusClient.readInputRegisters( ModbusReadResult result = modbusClient.readInputRegisters(
@@ -46,11 +52,17 @@ public class SensorTelemetryReader {
); );
Integer rawValue = result.values().getFirst(); Integer rawValue = result.values().getFirst();
Object value = convertValue(sensorDefinition, rawValue);
Object value = convertValue(
sensorDefinition,
rawValue
);
TelemetrySnapshot snapshot = new TelemetrySnapshot( TelemetrySnapshot snapshot = new TelemetrySnapshot(
sensorDefinition.getId(), sensorDefinition.getId(),
sensorDefinition.getKey(),
sensorDefinition.getName(), sensorDefinition.getName(),
sensorDefinition.getCategory(),
sensorDefinition.getModbusAddress(), sensorDefinition.getModbusAddress(),
sensorDefinition.getBitOffset(), sensorDefinition.getBitOffset(),
rawValue, rawValue,
@@ -64,19 +76,30 @@ public class SensorTelemetryReader {
return snapshot; return snapshot;
} }
private Object convertValue(SensorDefinition sensorDefinition, Integer rawValue) { private Object convertValue(
SensorDefinition sensorDefinition,
Integer rawValue
) {
if (sensorDefinition.getValueType() == SensorValueType.BOOLEAN) { if (sensorDefinition.getValueType() == SensorValueType.BOOLEAN) {
Integer bitOffset = sensorDefinition.getBitOffset(); Integer bitOffset = sensorDefinition.getBitOffset();
if (bitOffset == null) { if (bitOffset == null) {
throw new IllegalStateException("BOOLEAN sensor requires bitOffset."); throw new IllegalStateException(
"BOOLEAN sensor requires bitOffset."
);
} }
return ((rawValue >> bitOffset) & 1) == 1; return ((rawValue >> bitOffset) & 1) == 1;
} }
if (sensorDefinition.getValueType() == SensorValueType.DECIMAL) { if (sensorDefinition.getValueType() == SensorValueType.DECIMAL) {
return rawValue / Math.pow(10, sensorDefinition.getDecimalPlaces());
return rawValue / Math.pow(
10,
sensorDefinition.getDecimalPlaces()
);
} }
return rawValue; return rawValue;
@@ -4,7 +4,9 @@ import java.time.Instant;
public record TelemetrySnapshot( public record TelemetrySnapshot(
Integer sensorId, Integer sensorId,
String key,
String name, String name,
String category,
Integer modbusAddress, Integer modbusAddress,
Integer bitOffset, Integer bitOffset,
Integer rawValue, Integer rawValue,
@@ -1,6 +1,5 @@
package com.litoralregas.backend.historian; package com.litoralregas.backend.historian;
import com.litoralregas.backend.dashboard.DashboardOverviewResponse;
import com.litoralregas.backend.modules.shared.ModuleSensorResponse; import com.litoralregas.backend.modules.shared.ModuleSensorResponse;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -13,78 +12,12 @@ import java.util.Map;
@Service @Service
public class HistorianService { public class HistorianService {
private static final String SOURCE_DASHBOARD_OVERVIEW = "DASHBOARD_OVERVIEW";
private final HistorianSampleRepository historianSampleRepository; private final HistorianSampleRepository historianSampleRepository;
private static final String SOURCE_MODULE = "MODULE";
public HistorianService(HistorianSampleRepository historianSampleRepository) { public HistorianService(HistorianSampleRepository historianSampleRepository) {
this.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) @Transactional(readOnly = true)
public List<HistorianSeriesPoint> getSeries(String keyName, Instant from, Instant to) { public List<HistorianSeriesPoint> getSeries(String keyName, Instant from, Instant to) {
return historianSampleRepository return historianSampleRepository
@@ -126,7 +59,7 @@ public class HistorianService {
sample.setKeyName(keyName); sample.setKeyName(keyName);
sample.setNumericValue(value.doubleValue()); sample.setNumericValue(value.doubleValue());
sample.setUnit(unit); sample.setUnit(unit);
sample.setSource(SOURCE_DASHBOARD_OVERVIEW); sample.setSource(SOURCE_MODULE);
System.out.println("Saving historian sample: " + keyName + " = " + value); System.out.println("Saving historian sample: " + keyName + " = " + value);
historianSampleRepository.save(sample); historianSampleRepository.save(sample);
} }
@@ -138,7 +71,7 @@ public class HistorianService {
sample.setSampledAt(sampledAt); sample.setSampledAt(sampledAt);
sample.setKeyName(keyName); sample.setKeyName(keyName);
sample.setBooleanValue(value); sample.setBooleanValue(value);
sample.setSource(SOURCE_DASHBOARD_OVERVIEW); sample.setSource(SOURCE_MODULE);
historianSampleRepository.save(sample); historianSampleRepository.save(sample);
} }
@@ -154,13 +87,12 @@ public class HistorianService {
@Transactional @Transactional
public void recordModuleSensors( public void recordModuleSensors(
String moduleName,
List<ModuleSensorResponse> sensors, List<ModuleSensorResponse> sensors,
Instant sampledAt Instant sampledAt
) { ) {
for (ModuleSensorResponse sensor : sensors) { for (ModuleSensorResponse sensor : sensors) {
String keyName = moduleName + "." + sensor.key(); String key = sensor.key();
Object value = sensor.value(); Object value = sensor.value();
@@ -168,7 +100,7 @@ public class HistorianService {
recordNumber( recordNumber(
sampledAt, sampledAt,
keyName, key,
numberValue, numberValue,
sensor.unit() sensor.unit()
); );
@@ -177,7 +109,7 @@ public class HistorianService {
recordBoolean( recordBoolean(
sampledAt, sampledAt,
keyName, key,
booleanValue booleanValue
); );
} }
@@ -81,6 +81,7 @@ public class ClimateModuleService {
private ModuleSensorResponse toResponse(TelemetrySnapshot snapshot) { private ModuleSensorResponse toResponse(TelemetrySnapshot snapshot) {
return new ModuleSensorResponse( return new ModuleSensorResponse(
snapshot.sensorId(), snapshot.sensorId(),
snapshot.key(),
snapshot.name(), snapshot.name(),
buildKey(snapshot.name()), buildKey(snapshot.name()),
snapshot.value(), snapshot.value(),
@@ -1,4 +1,24 @@
package com.litoralregas.backend.modules.climate.websocket; package com.litoralregas.backend.modules.climate.websocket;
import com.litoralregas.backend.modules.climate.ClimateModuleResponse;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
@Service
public class ClimateModuleWebSocketPublisher { public class ClimateModuleWebSocketPublisher {
}
private static final String DESTINATION =
"/topic/modules/climate/latest";
private final SimpMessagingTemplate messagingTemplate;
public ClimateModuleWebSocketPublisher(
SimpMessagingTemplate messagingTemplate
) {
this.messagingTemplate = messagingTemplate;
}
public void publishLatest(ClimateModuleResponse response) {
messagingTemplate.convertAndSend(DESTINATION, response);
}
}
@@ -45,6 +45,7 @@ public class MeteoModuleService {
private ModuleSensorResponse toResponse(TelemetrySnapshot snapshot) { private ModuleSensorResponse toResponse(TelemetrySnapshot snapshot) {
return new ModuleSensorResponse( return new ModuleSensorResponse(
snapshot.sensorId(), snapshot.sensorId(),
snapshot.key(),
snapshot.name(), snapshot.name(),
buildKey(snapshot.name()), buildKey(snapshot.name()),
snapshot.value(), snapshot.value(),
@@ -4,8 +4,9 @@ import java.time.Instant;
public record ModuleSensorResponse( public record ModuleSensorResponse(
Integer sensorId, Integer sensorId,
String name,
String key, String key,
String name,
String category,
Object value, Object value,
String unit, String unit,
Integer modbusAddress, Integer modbusAddress,
@@ -1,6 +1,7 @@
package com.litoralregas.backend.sensor; package com.litoralregas.backend.sensor;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.time.Instant; import java.time.Instant;
@Entity @Entity
@@ -11,10 +12,16 @@ public class SensorDefinition {
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id; private Integer id;
@Column(unique = true, nullable = false)
private String key;
@Column(nullable = false) @Column(nullable = false)
private String name; private String name;
@Column(name = "modbus_address", nullable = false) @Column(nullable = false)
private String category;
@Column(name = "modbus_address")
private Integer modbusAddress; private Integer modbusAddress;
@Column(name = "bit_offset") @Column(name = "bit_offset")
@@ -29,9 +36,6 @@ public class SensorDefinition {
@Column(name = "decimal_places", nullable = false) @Column(name = "decimal_places", nullable = false)
private Integer decimalPlaces; private Integer decimalPlaces;
@Column(nullable = false)
private String category;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "source_type", nullable = false) @Column(name = "source_type", nullable = false)
private SensorSourceType sourceType; private SensorSourceType sourceType;
@@ -49,24 +53,26 @@ public class SensorDefinition {
} }
public SensorDefinition( public SensorDefinition(
String key,
String name, String name,
String category,
Integer modbusAddress, Integer modbusAddress,
Integer bitOffset, Integer bitOffset,
SensorValueType valueType, SensorValueType valueType,
String unit, String unit,
Integer decimalPlaces, Integer decimalPlaces,
String category,
SensorSourceType sourceType, SensorSourceType sourceType,
Integer pollingIntervalSeconds, Integer pollingIntervalSeconds,
Boolean enabled Boolean enabled
) { ) {
this.key = key;
this.name = name; this.name = name;
this.category = category;
this.modbusAddress = modbusAddress; this.modbusAddress = modbusAddress;
this.bitOffset = bitOffset; this.bitOffset = bitOffset;
this.valueType = valueType; this.valueType = valueType;
this.unit = unit; this.unit = unit;
this.decimalPlaces = decimalPlaces; this.decimalPlaces = decimalPlaces;
this.category = category;
this.sourceType = sourceType; this.sourceType = sourceType;
this.pollingIntervalSeconds = pollingIntervalSeconds; this.pollingIntervalSeconds = pollingIntervalSeconds;
this.enabled = enabled; this.enabled = enabled;
@@ -77,6 +83,14 @@ public class SensorDefinition {
return id; return id;
} }
public String getKey() {
return key;
}
public void setKey(String key) {
this.key = key;
}
public String getName() { public String getName() {
return name; return name;
} }
@@ -85,6 +99,14 @@ public class SensorDefinition {
this.name = name; this.name = name;
} }
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public Integer getModbusAddress() { public Integer getModbusAddress() {
return modbusAddress; return modbusAddress;
} }
@@ -125,14 +147,6 @@ public class SensorDefinition {
this.decimalPlaces = decimalPlaces; this.decimalPlaces = decimalPlaces;
} }
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public SensorSourceType getSourceType() { public SensorSourceType getSourceType() {
return sourceType; return sourceType;
} }
@@ -1,16 +0,0 @@
package com.litoralregas.backend.sensor.dto;
import com.litoralregas.backend.sensor.SensorSourceType;
import com.litoralregas.backend.sensor.SensorValueType;
public record SensorDefinitionImportRow(
String name,
Integer modbusAddress,
Integer bitOffset,
SensorValueType valueType,
String unit,
Integer decimalPlaces,
String category,
SensorSourceType sourceType
) {
}
@@ -0,0 +1,7 @@
package com.litoralregas.backend.sensor.importer;
public record ModbusConfig(
Integer address,
Integer bitOffset
) {
}
@@ -0,0 +1,14 @@
package com.litoralregas.backend.sensor.importer;
public record SensorDefinitionConfig(
String key,
String name,
String category,
ModbusConfig modbus,
String valueType,
String unit,
Integer decimalPlaces,
Integer pollingIntervalSeconds,
Boolean enabled
) {
}
@@ -15,6 +15,6 @@ public class SensorDefinitionImportController {
@PostMapping("/api/sensor-definition-import/run") @PostMapping("/api/sensor-definition-import/run")
public SensorDefinitionImportResult runImport() { public SensorDefinitionImportResult runImport() {
return importService.importSensorMap(); return importService.importSensorDefinitions();
} }
} }
@@ -1,113 +1,104 @@
package com.litoralregas.backend.sensor.importer; package com.litoralregas.backend.sensor.importer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.litoralregas.backend.sensor.SensorDefinition; import com.litoralregas.backend.sensor.SensorDefinition;
import com.litoralregas.backend.sensor.SensorDefinitionRepository; import com.litoralregas.backend.sensor.SensorDefinitionRepository;
import com.litoralregas.backend.sensor.SensorSourceType;
import com.litoralregas.backend.sensor.SensorValueType;
import com.litoralregas.backend.sensor.dto.SensorDefinitionImportResult; import com.litoralregas.backend.sensor.dto.SensorDefinitionImportResult;
import com.litoralregas.backend.sensor.dto.SensorDefinitionImportRow;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets; import java.io.InputStream;
import java.util.List;
@Service @Service
public class SensorDefinitionImportService { public class SensorDefinitionImportService {
private static final String SENSOR_MAP_PATH = "config/sensor-map.txt"; private static final String SENSOR_DEFINITIONS_PATH =
private static final int DEFAULT_POLLING_INTERVAL_SECONDS = 2; "config/sensor-definitions.json";
private final SensorDefinitionMapParser parser;
private final SensorDefinitionRepository repository; private final SensorDefinitionRepository repository;
private final ObjectMapper objectMapper;
public SensorDefinitionImportService( public SensorDefinitionImportService(
SensorDefinitionMapParser parser, SensorDefinitionRepository repository,
SensorDefinitionRepository repository ObjectMapper objectMapper
) { ) {
this.parser = parser;
this.repository = repository; this.repository = repository;
this.objectMapper = objectMapper;
} }
@Transactional @Transactional
public SensorDefinitionImportResult importSensorMap() { public SensorDefinitionImportResult importSensorDefinitions() {
List<String> lines = readSensorMapLines();
SensorDefinitionsFile file = readDefinitionsFile();
int imported = 0; int imported = 0;
int skippedExisting = 0; int skippedExisting = 0;
int skippedBlank = 0;
for (String line : lines) { for (SensorDefinitionConfig config : file.sensors()) {
if (line == null || line.isBlank()) {
skippedBlank++;
continue;
}
SensorDefinitionImportRow row = parser.parseLine(line) if (repository.findByName(config.name()).isPresent()) {
.orElseThrow(() -> new IllegalArgumentException(
"Invalid sensor row: " + line
));
List<SensorDefinition> existingSensors = repository.findAllByHardwareAddress(
row.modbusAddress(),
row.bitOffset()
);
if (!existingSensors.isEmpty()) {
for (SensorDefinition sensorDefinition : existingSensors) {
sensorDefinition.setValueType(row.valueType());
sensorDefinition.setUnit(row.unit());
sensorDefinition.setDecimalPlaces(row.decimalPlaces());
sensorDefinition.setCategory(row.category());
sensorDefinition.setSourceType(row.sourceType());
}
skippedExisting += existingSensors.size();
continue;
}
if (repository.findByName(row.name()).isPresent()) {
skippedExisting++; skippedExisting++;
continue; continue;
} }
SensorDefinition sensorDefinition = new SensorDefinition( SensorDefinition sensorDefinition = new SensorDefinition(
row.name(), config.key(),
row.modbusAddress(), config.name(),
row.bitOffset(), config.category(),
row.valueType(), config.modbus().address(),
row.unit(), config.modbus().bitOffset(),
row.decimalPlaces(), SensorValueType.valueOf(config.valueType()),
row.category(), config.unit(),
row.sourceType(), config.decimalPlaces(),
DEFAULT_POLLING_INTERVAL_SECONDS, SensorSourceType.MODBUS,
true config.pollingIntervalSeconds(),
config.enabled()
); );
repository.save(sensorDefinition); repository.save(sensorDefinition);
imported++; imported++;
} }
return new SensorDefinitionImportResult( return new SensorDefinitionImportResult(
lines.size(), file.sensorCount(),
imported, imported,
skippedExisting, skippedExisting,
skippedBlank 0
); );
} }
private List<String> readSensorMapLines() { private SensorDefinitionsFile readDefinitionsFile() {
try { try {
ClassPathResource resource = new ClassPathResource(SENSOR_MAP_PATH);
ClassPathResource resource =
new ClassPathResource(SENSOR_DEFINITIONS_PATH);
if (!resource.exists()) { if (!resource.exists()) {
throw new IllegalStateException("Sensor map file not found: " + SENSOR_MAP_PATH); throw new IllegalStateException(
"Sensor definitions file not found: "
+ SENSOR_DEFINITIONS_PATH
);
}
try (InputStream inputStream = resource.getInputStream()) {
return objectMapper.readValue(
inputStream,
SensorDefinitionsFile.class
);
} }
return resource.getContentAsString(StandardCharsets.UTF_8)
.lines()
.toList();
} catch (Exception exception) { } catch (Exception exception) {
throw new IllegalStateException("Failed to read sensor map file.", exception);
throw new IllegalStateException(
"Failed to load sensor definitions.",
exception
);
} }
} }
} }
@@ -1,96 +0,0 @@
package com.litoralregas.backend.sensor.importer;
import com.litoralregas.backend.sensor.SensorSourceType;
import com.litoralregas.backend.sensor.SensorValueType;
import com.litoralregas.backend.sensor.dto.SensorDefinitionImportRow;
import org.springframework.stereotype.Component;
import java.util.Optional;
@Component
public class SensorDefinitionMapParser {
public Optional<SensorDefinitionImportRow> parseLine(String line) {
if (line == null || line.isBlank()) {
return Optional.empty();
}
String[] parts = line.split("\\*");
if (parts.length != 5) {
throw new IllegalArgumentException("Invalid sensor map line: " + line);
}
String name = parts[0].trim();
String addressPart = parts[1].trim();
Integer decimalPlaces = Integer.parseInt(parts[2].trim());
String unit = normalizeUnit(parts[3].trim());
String category = mapCategory(parts[4].trim());
ParsedAddress parsedAddress = parseAddress(addressPart);
SensorSourceType sourceType = parsedAddress.modbusAddress() < 0
? SensorSourceType.CALCULATED
: SensorSourceType.MODBUS;
SensorValueType valueType = parsedAddress.bitOffset() != null
? SensorValueType.BOOLEAN
: decimalPlaces > 0 ? SensorValueType.DECIMAL : SensorValueType.INTEGER;
return Optional.of(new SensorDefinitionImportRow(
name,
parsedAddress.modbusAddress(),
parsedAddress.bitOffset(),
valueType,
unit,
decimalPlaces,
category,
sourceType
));
}
private ParsedAddress parseAddress(String addressPart) {
if (addressPart.contains(",")) {
String[] addressParts = addressPart.split(",");
if (addressParts.length != 2) {
throw new IllegalArgumentException("Invalid bit address: " + addressPart);
}
return new ParsedAddress(
Integer.parseInt(addressParts[0].trim()),
Integer.parseInt(addressParts[1].trim())
);
}
return new ParsedAddress(
Integer.parseInt(addressPart),
null
);
}
private String normalizeUnit(String unit) {
if (unit == null || unit.isBlank() || unit.equalsIgnoreCase("SU")) {
return null;
}
return unit;
}
private String mapCategory(String categoryCode) {
return switch (categoryCode.toLowerCase()) {
case "c" -> "CLIMATE";
case "r" -> "IRRIGATION";
case "i" -> "LIGHTING";
case "h" -> "HYDRO";
case "a" -> "AEROPONICS";
default -> "UNKNOWN";
};
}
private record ParsedAddress(
Integer modbusAddress,
Integer bitOffset
) {
}
}
@@ -0,0 +1,35 @@
package com.litoralregas.backend.sensor.importer;
import com.litoralregas.backend.sensor.SensorDefinitionRepository;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class SensorDefinitionStartupImporter {
private final SensorDefinitionRepository repository;
private final SensorDefinitionImportService importService;
public SensorDefinitionStartupImporter(
SensorDefinitionRepository repository,
SensorDefinitionImportService importService
) {
this.repository = repository;
this.importService = importService;
}
@EventListener(ApplicationReadyEvent.class)
public void importSensorsIfMissing() {
if (repository.count() > 0) {
System.out.println("Sensor definitions already imported. Skipping startup import.");
return;
}
System.out.println("No sensor definitions found. Importing sensor-definitions.json...");
importService.importSensorDefinitions();
System.out.println("Sensor definitions imported successfully.");
}
}
@@ -0,0 +1,10 @@
package com.litoralregas.backend.sensor.importer;
import java.util.List;
public record SensorDefinitionsFile(
Integer version,
Integer sensorCount,
List<SensorDefinitionConfig> sensors
) {
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,14 +1,27 @@
CREATE TABLE sensor_definition ( CREATE TABLE sensor_definition (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
key VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
modbus_address INTEGER NOT NULL,
bit_offset INTEGER,
value_type VARCHAR(50) NOT NULL,
unit VARCHAR(50),
decimal_places INTEGER NOT NULL DEFAULT 0,
category VARCHAR(100) NOT NULL, category VARCHAR(100) NOT NULL,
modbus_address INTEGER,
bit_offset INTEGER,
value_type VARCHAR(50) NOT NULL,
unit VARCHAR(50),
decimal_places INTEGER NOT NULL DEFAULT 0,
source_type VARCHAR(50) NOT NULL, source_type VARCHAR(50) NOT NULL,
polling_interval_seconds INTEGER NOT NULL DEFAULT 1, polling_interval_seconds INTEGER NOT NULL DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT TRUE, enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL created_at TIMESTAMP NOT NULL
); );
@@ -2,6 +2,7 @@ CREATE TABLE historian_sample (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
sampled_at TIMESTAMP NOT NULL, sampled_at TIMESTAMP NOT NULL,
key_name VARCHAR(160) NOT NULL, key_name VARCHAR(160) NOT NULL,
numeric_value REAL, numeric_value REAL,
@@ -9,6 +10,7 @@ CREATE TABLE historian_sample (
text_value VARCHAR(255), text_value VARCHAR(255),
unit VARCHAR(32), unit VARCHAR(32),
source VARCHAR(50) NOT NULL, source VARCHAR(50) NOT NULL,
created_at TIMESTAMP NOT NULL created_at TIMESTAMP NOT NULL
@@ -1,52 +0,0 @@
INSERT INTO sensor_definition (
name,
modbus_address,
bit_offset,
value_type,
unit,
decimal_places,
category,
source_type,
polling_interval_seconds,
enabled,
created_at
) VALUES
(
'Greenhouse Temperature',
100,
NULL,
'DECIMAL',
'ºC',
1,
'CLIMATE',
'MODBUS',
2,
TRUE,
CURRENT_TIMESTAMP
),
(
'Greenhouse Humidity',
101,
NULL,
'DECIMAL',
'%',
1,
'CLIMATE',
'MODBUS',
2,
TRUE,
CURRENT_TIMESTAMP
),
(
'Irrigation Pump Running',
200,
0,
'BOOLEAN',
NULL,
0,
'IRRIGATION',
'MODBUS',
1,
TRUE,
CURRENT_TIMESTAMP
);
@@ -1,2 +0,0 @@
CREATE UNIQUE INDEX ux_sensor_definition_name
ON sensor_definition(name);