Optimize acquisition polling with register blocks

This commit is contained in:
litoral05
2026-05-19 17:14:24 +01:00
parent 49f8d944eb
commit 571ed54c29
9 changed files with 480 additions and 1 deletions
@@ -0,0 +1,10 @@
package com.litoralregas.backend.acquisition;
import java.util.List;
public record AcquisitionBlock(
Integer startingAddress,
Integer quantity,
List<Integer> sensorIds
) {
}
@@ -0,0 +1,89 @@
package com.litoralregas.backend.acquisition;
import com.litoralregas.backend.sensor.SensorDefinition;
import com.litoralregas.backend.sensor.SensorDefinitionRepository;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
@Service
public class AcquisitionBlockPlanner {
private static final int BLOCK_SIZE = 40;
private final AcquisitionPlanBuilder acquisitionPlanBuilder;
private final SensorDefinitionRepository sensorDefinitionRepository;
public AcquisitionBlockPlanner(
AcquisitionPlanBuilder acquisitionPlanBuilder,
SensorDefinitionRepository sensorDefinitionRepository
) {
this.acquisitionPlanBuilder = acquisitionPlanBuilder;
this.sensorDefinitionRepository = sensorDefinitionRepository;
}
public List<AcquisitionBlock> buildBlocks() {
AcquisitionPlan plan = acquisitionPlanBuilder.buildPlan();
List<SensorDefinition> sensors = sensorDefinitionRepository.findAllById(plan.sensorIds())
.stream()
.sorted(Comparator.comparing(SensorDefinition::getModbusAddress))
.toList();
List<AcquisitionBlock> blocks = new ArrayList<>();
List<Integer> currentSensorIds = new ArrayList<>();
Integer currentBlockStart = null;
Integer currentBlockEnd = null;
for (SensorDefinition sensor : sensors) {
Integer address = sensor.getModbusAddress();
if (address == null || address < 0) {
continue;
}
if (currentBlockStart == null) {
currentBlockStart = address;
currentBlockEnd = address;
currentSensorIds.add(sensor.getId());
continue;
}
boolean fitsCurrentBlock =
address <= currentBlockStart + BLOCK_SIZE
&& (address - currentBlockStart) <= BLOCK_SIZE;
if (fitsCurrentBlock) {
currentBlockEnd = address;
currentSensorIds.add(sensor.getId());
} else {
blocks.add(new AcquisitionBlock(
currentBlockStart,
(currentBlockEnd - currentBlockStart) + 1,
List.copyOf(currentSensorIds)
));
currentSensorIds.clear();
currentBlockStart = address;
currentBlockEnd = address;
currentSensorIds.add(sensor.getId());
}
}
if (currentBlockStart != null && !currentSensorIds.isEmpty()) {
blocks.add(new AcquisitionBlock(
currentBlockStart,
(currentBlockEnd - currentBlockStart) + 1,
List.copyOf(currentSensorIds)
));
}
return blocks;
}
}
@@ -0,0 +1,37 @@
package com.litoralregas.backend.acquisition;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class AcquisitionController {
private final AcquisitionPlanBuilder acquisitionPlanBuilder;
private final AcquisitionPollingService acquisitionPollingService;
private final BlockPollingService blockPollingService;
public AcquisitionController(
AcquisitionPlanBuilder acquisitionPlanBuilder,
AcquisitionPollingService acquisitionPollingService,
BlockPollingService blockPollingService
) {
this.acquisitionPlanBuilder = acquisitionPlanBuilder;
this.acquisitionPollingService = acquisitionPollingService;
this.blockPollingService = blockPollingService;
}
@GetMapping("/api/acquisition/plan")
public AcquisitionPlan getPlan() {
return acquisitionPlanBuilder.buildPlan();
}
@PostMapping("/api/acquisition/poll-once")
public AcquisitionPollResult pollOnce() {
return acquisitionPollingService.pollOnce();
}
@PostMapping("/api/acquisition/poll-once-blocks")
public AcquisitionPollResult pollOnceByBlocks() {
return blockPollingService.pollOnceByBlocks();
}
}
@@ -0,0 +1,16 @@
package com.litoralregas.backend.acquisition;
import java.time.Instant;
import java.util.List;
public record AcquisitionPlan(
Integer climateGreenhouseCount,
Integer irrigationControllerCount,
Boolean climateEnabled,
Boolean irrigationEnabled,
Boolean lightingEnabled,
Integer sensorCount,
List<Integer> sensorIds,
Instant timestamp
) {
}
@@ -0,0 +1,112 @@
package com.litoralregas.backend.acquisition;
import com.litoralregas.backend.sensor.SensorDefinition;
import com.litoralregas.backend.sensor.SensorDefinitionRepository;
import com.litoralregas.backend.sensor.SensorSourceType;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
@Service
public class AcquisitionPlanBuilder {
private final ControllerCapabilitiesReader capabilitiesReader;
private final SensorDefinitionRepository sensorDefinitionRepository;
public AcquisitionPlanBuilder(
ControllerCapabilitiesReader capabilitiesReader,
SensorDefinitionRepository sensorDefinitionRepository
) {
this.capabilitiesReader = capabilitiesReader;
this.sensorDefinitionRepository = sensorDefinitionRepository;
}
public AcquisitionPlan buildPlan() {
ControllerCapabilities capabilities = capabilitiesReader.readCapabilities();
List<Integer> sensorIds = sensorDefinitionRepository.findByEnabledTrueOrderByNameAsc()
.stream()
.filter(sensor -> sensor.getSourceType() == SensorSourceType.MODBUS)
.filter(sensor -> shouldPollSensor(sensor, capabilities))
.map(SensorDefinition::getId)
.sorted()
.toList();
return new AcquisitionPlan(
capabilities.climateGreenhouseCount(),
capabilities.irrigationControllerCount(),
capabilities.climateEnabled(),
capabilities.irrigationEnabled(),
capabilities.lightingEnabled(),
sensorIds.size(),
sensorIds,
Instant.now()
);
}
private boolean shouldPollSensor(
SensorDefinition sensor,
ControllerCapabilities capabilities
) {
String category = sensor.getCategory();
if ("CLIMATE".equals(category)) {
return capabilities.climateEnabled()
&& belongsToEnabledClimateRange(sensor, capabilities.climateGreenhouseCount());
}
if ("IRRIGATION".equals(category)) {
return capabilities.irrigationEnabled()
&& belongsToEnabledIrrigationRange(sensor, capabilities.irrigationControllerCount());
}
if ("LIGHTING".equals(category)) {
return capabilities.lightingEnabled();
}
return false;
}
private boolean belongsToEnabledClimateRange(
SensorDefinition sensor,
Integer greenhouseCount
) {
Integer address = sensor.getModbusAddress();
if (address == null || address < 0) {
return false;
}
if (address >= 10 && address <= 22) {
return true;
}
if (address < 100 || address > 899) {
return false;
}
int greenhouseNumber = ((address - 100) / 40) + 1;
return greenhouseNumber >= 1 && greenhouseNumber <= greenhouseCount;
}
private boolean belongsToEnabledIrrigationRange(
SensorDefinition sensor,
Integer controllerCount
) {
Integer address = sensor.getModbusAddress();
if (address == null || address < 0) {
return false;
}
if (address < 1000 || address > 1599) {
return false;
}
int controllerNumber = ((address - 1000) / 100) + 1;
return controllerNumber >= 1 && controllerNumber <= controllerCount;
}
}
@@ -0,0 +1,14 @@
package com.litoralregas.backend.acquisition;
import java.time.Instant;
import java.util.List;
public record AcquisitionPollResult(
Integer plannedSensorCount,
Integer successfulReads,
Integer failedReads,
List<Integer> failedSensorIds,
Instant startedAt,
Instant finishedAt
) {
}
@@ -0,0 +1,52 @@
package com.litoralregas.backend.acquisition;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Service
public class AcquisitionPollingService {
private final AcquisitionPlanBuilder acquisitionPlanBuilder;
private final SensorTelemetryReader sensorTelemetryReader;
public AcquisitionPollingService(
AcquisitionPlanBuilder acquisitionPlanBuilder,
SensorTelemetryReader sensorTelemetryReader
) {
this.acquisitionPlanBuilder = acquisitionPlanBuilder;
this.sensorTelemetryReader = sensorTelemetryReader;
}
public AcquisitionPollResult pollOnce() {
Instant startedAt = Instant.now();
AcquisitionPlan plan = acquisitionPlanBuilder.buildPlan();
int successfulReads = 0;
List<Integer> failedSensorIds = new ArrayList<>();
for (Integer sensorId : plan.sensorIds()) {
try {
sensorTelemetryReader.readSensor(sensorId);
successfulReads++;
} catch (Exception exception) {
failedSensorIds.add(sensorId);
}
}
Instant finishedAt = Instant.now();
return new AcquisitionPollResult(
plan.sensorCount(),
successfulReads,
failedSensorIds.size(),
failedSensorIds,
startedAt,
finishedAt
);
}
}
@@ -0,0 +1,138 @@
package com.litoralregas.backend.acquisition;
import com.litoralregas.backend.modbus.LrModbusClient;
import com.litoralregas.backend.modbus.ModbusReadResult;
import com.litoralregas.backend.modbus.ModbusUnit;
import com.litoralregas.backend.sensor.SensorDefinition;
import com.litoralregas.backend.sensor.SensorDefinitionRepository;
import com.litoralregas.backend.sensor.SensorValueType;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class BlockPollingService {
private final AcquisitionBlockPlanner blockPlanner;
private final SensorDefinitionRepository sensorDefinitionRepository;
private final LrModbusClient modbusClient;
private final TelemetryCache telemetryCache;
public BlockPollingService(
AcquisitionBlockPlanner blockPlanner,
SensorDefinitionRepository sensorDefinitionRepository,
LrModbusClient modbusClient,
TelemetryCache telemetryCache
) {
this.blockPlanner = blockPlanner;
this.sensorDefinitionRepository = sensorDefinitionRepository;
this.modbusClient = modbusClient;
this.telemetryCache = telemetryCache;
}
public AcquisitionPollResult pollOnceByBlocks() {
Instant startedAt = Instant.now();
List<AcquisitionBlock> blocks = blockPlanner.buildBlocks();
List<Integer> allSensorIds = blocks.stream()
.flatMap(block -> block.sensorIds().stream())
.toList();
Map<Integer, SensorDefinition> sensorsById = sensorDefinitionRepository.findAllById(allSensorIds)
.stream()
.collect(Collectors.toMap(SensorDefinition::getId, Function.identity()));
int successfulReads = 0;
List<Integer> failedSensorIds = new ArrayList<>();
for (AcquisitionBlock block : blocks) {
try {
ModbusReadResult result = modbusClient.readInputRegisters(
ModbusUnit.PC,
block.startingAddress(),
block.quantity()
);
for (Integer sensorId : block.sensorIds()) {
SensorDefinition sensor = sensorsById.get(sensorId);
if (sensor == null) {
failedSensorIds.add(sensorId);
continue;
}
try {
Integer rawValue = rawValueForSensor(sensor, block, result);
Object value = convertValue(sensor, rawValue);
telemetryCache.put(new TelemetrySnapshot(
sensor.getId(),
sensor.getName(),
sensor.getModbusAddress(),
sensor.getBitOffset(),
rawValue,
value,
sensor.getUnit(),
Instant.now()
));
successfulReads++;
} catch (Exception exception) {
failedSensorIds.add(sensorId);
}
}
} catch (Exception exception) {
failedSensorIds.addAll(block.sensorIds());
}
}
Instant finishedAt = Instant.now();
return new AcquisitionPollResult(
allSensorIds.size(),
successfulReads,
failedSensorIds.size(),
failedSensorIds,
startedAt,
finishedAt
);
}
private Integer rawValueForSensor(
SensorDefinition sensor,
AcquisitionBlock block,
ModbusReadResult result
) {
int offset = sensor.getModbusAddress() - block.startingAddress();
if (offset < 0 || offset >= result.values().size()) {
throw new IllegalStateException("Sensor address outside acquisition block.");
}
return result.values().get(offset);
}
private Object convertValue(SensorDefinition sensor, Integer rawValue) {
if (sensor.getValueType() == SensorValueType.BOOLEAN) {
Integer bitOffset = sensor.getBitOffset();
if (bitOffset == null) {
throw new IllegalStateException("BOOLEAN sensor requires bitOffset.");
}
return ((rawValue >> bitOffset) & 1) == 1;
}
if (sensor.getValueType() == SensorValueType.DECIMAL) {
return rawValue / Math.pow(10, sensor.getDecimalPlaces());
}
return rawValue;
}
}
@@ -3,18 +3,29 @@ package com.litoralregas.backend.acquisition;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.Collection;
@RestController @RestController
public class TelemetryController { public class TelemetryController {
private final SensorTelemetryReader sensorTelemetryReader; private final SensorTelemetryReader sensorTelemetryReader;
private final TelemetryCache telemetryCache;
public TelemetryController(SensorTelemetryReader sensorTelemetryReader) { public TelemetryController(
SensorTelemetryReader sensorTelemetryReader,
TelemetryCache telemetryCache
) {
this.sensorTelemetryReader = sensorTelemetryReader; this.sensorTelemetryReader = sensorTelemetryReader;
this.telemetryCache = telemetryCache;
} }
@GetMapping("/api/telemetry/sensors/{sensorId}") @GetMapping("/api/telemetry/sensors/{sensorId}")
public TelemetrySnapshot readSensor(@PathVariable Integer sensorId) { public TelemetrySnapshot readSensor(@PathVariable Integer sensorId) {
return sensorTelemetryReader.readSensor(sensorId); return sensorTelemetryReader.readSensor(sensorId);
} }
@GetMapping("/api/telemetry/latest")
public Collection<TelemetrySnapshot> getLatest() {
return telemetryCache.getAll();
}
} }