diff --git a/README.md b/README.md new file mode 100644 index 0000000..b55aa43 --- /dev/null +++ b/README.md @@ -0,0 +1,538 @@ +# LitoralRegas Backend + +Spring Boot backend for the LitoralRegas agricultural monitoring and control platform. + +This backend communicates with agricultural controllers through Modbus, builds dynamic acquisition plans based on installed modules, collects live telemetry, stores historical data, and exposes APIs consumed by the frontend dashboard. + +--- + +# Features + +* Modbus TCP communication +* Dynamic controller capability discovery +* Sensor definition import system +* Live telemetry acquisition +* Telemetry cache layer +* Historical telemetry storage +* Climate module API +* Meteorology module API +* Irrigation module foundation +* Historical chart aggregation endpoints +* SQLite persistence + +--- + +# Technology Stack + +* Java 21 +* Spring Boot +* Spring Web +* Spring Data JPA +* SQLite +* Maven +* Modbus TCP + +--- + +# Architecture Overview + +```txt +Sensor Definitions + ↓ +Controller Capabilities + ↓ +Acquisition Plan Builder + ↓ +Telemetry Acquisition Scheduler + ↓ +Telemetry Cache + ↓ +Historian + Module APIs + ↓ +Frontend Dashboard +``` + +--- + +# Core Concepts + +## Sensor Definitions + +The `sensor_definition` table contains the full catalog of known sensors. + +A controller installation may not use every sensor present in the catalog. + +The acquisition plan decides which sensors are actually polled. + +--- + +## Controller Capabilities + +Controller capabilities are read directly from Modbus registers. + +Current capability model: + +```txt +Register 6 → irrigationControllerCount +Register 7 → fertilizerChannelCount +Register 8 → feature flags +Register 9 → climateGreenhouseCount +``` + +Feature flags: + +```txt +bit 0 → climateEnabled +bit 1 → irrigationEnabled +bit 2 → lightingEnabled +``` + +--- + +## Acquisition Plan + +The acquisition plan dynamically selects sensors based on: + +* Installed controller modules +* Greenhouse count +* Irrigation controller count +* Sensor category +* Modbus address ranges + +This prevents polling sensors that do not exist in a specific installation. + +--- + +## Telemetry Cache + +The telemetry cache stores the latest acquired value for each sensor. + +Module APIs read from the cache instead of directly querying Modbus. + +--- + +## Historian + +The historian stores telemetry over time. + +It supports: + +* historical chart series +* accumulated values +* time range queries +* future workspace/chart persistence + +--- + +# Project Structure + +```txt +src/main/java/com/litoralregas/backend +├── acquisition +├── historian +├── modbus +├── modules +│ ├── climate +│ ├── irrigation +│ ├── meteo +│ └── shared +├── sensor +├── telemetry +└── config +``` + +--- + +# Running the Backend + +## Requirements + +* Java 21+ +* Maven +* SQLite +* Reachable controller or Modbus simulator + +--- + +## Start Backend + +```bash +mvn spring-boot:run +``` + +Default backend URL: + +```txt +http://localhost:18450 +``` + +--- + +# Configuration + +Main configuration file: + +```txt +src/main/resources/application.yaml +``` + +Example acquisition scheduler configuration: + +```yaml +litoralregas: + acquisition: + scheduler: + enabled: true + fixed-delay-millis: 10000 +``` + +Important: + +YAML comments must be on a separate line. + +Correct: + +```yaml +fixed-delay-millis: 10000 +# Longer delay between acquisition cycles +``` + +Wrong: + +```yaml +fixed-delay-millis: 10000 // comment +``` + +--- + +# Database + +Current database engine: + +```txt +SQLite +``` + +Main table: + +```sql +CREATE TABLE sensor_definition ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + 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, + source_type VARCHAR(50) NOT NULL, + polling_interval_seconds INTEGER NOT NULL DEFAULT 1, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL +); +``` + +--- + +# Sensor Definition Import + +Sensor definitions are imported from: + +```txt +src/main/resources/config/sensor-map.txt +``` + +Run import manually: + +```bash +curl -X POST http://localhost:18450/api/sensor-definition-import/run +``` + +The importer: + +* imports missing sensors +* updates safe metadata +* skips duplicates safely +* supports clean reimports during development + +--- + +# Clean Development Reimport + +To completely reset sensor definitions during development: + +```sql +DELETE FROM sensor_definition; + +DELETE FROM sqlite_sequence +WHERE name = 'sensor_definition'; +``` + +Then re-run: + +```bash +curl -X POST http://localhost:18450/api/sensor-definition-import/run +``` + +--- + +# Main API Endpoints + +## Acquisition Plan + +```http +GET /api/acquisition/plan +``` + +Returns: + +* module availability +* greenhouse count +* irrigation controller count +* selected sensor IDs + +--- + +## Latest Telemetry + +```http +GET /api/telemetry/latest +``` + +Returns latest cached telemetry values. + +--- + +## Meteo Module + +```http +GET /api/modules/meteo +``` + +Provides: + +* exterior temperature +* exterior humidity +* wind speed +* wind direction +* radiation +* rain sensors +* CO2 overview + +--- + +## Climate Module + +```http +GET /api/modules/climate +``` + +Provides: + +* greenhouse temperature +* greenhouse humidity +* CO2 +* ventilation +* extractors +* screens +* windows +* lighting sectors +* soil humidity +* soil temperature + +--- + +## Historical Accumulation + +```http +GET /api/historian/accumulated +``` + +Example: + +```http +GET /api/historian/accumulated?key=meteo.chuva.1&range=30d +``` + +Supported ranges: + +```txt +7d +30d +month +year +``` + +--- + +# Dynamic Module Strategy + +Module availability is determined dynamically through: + +```txt +ControllerCapabilities + ↓ +AcquisitionPlan + ↓ +TelemetryCache +``` + +This means: + +* climate sensors are only acquired if climate exists +* irrigation sensors are only acquired if irrigation exists +* lighting sensors are only acquired if lighting exists + +The frontend should eventually use acquisition plan data to decide which sections to render. + +--- + +# Derived Sensors + +Some sensors are virtual/computed values. + +Examples: + +```txt +DPV Estufa 1 → -121 +Hum. Absoluta 1 → -141 +``` + +These are NOT real Modbus registers. + +Correct pipeline: + +```txt +Raw temperature + humidity + ↓ +DerivedClimateService + ↓ +DPV / Absolute Humidity + ↓ +TelemetryCache + Historian +``` + +Negative Modbus addresses should never be polled directly. + +--- + +# Current Development Notes + +Disconnected sensors may return unrealistic values. + +Examples: + +* invalid temperature values +* unrealistic humidity values +* disconnected soil probes + +This is expected in partially installed environments. + +The backend currently exposes all acquired sensors. + +The frontend chart builder will later allow users to choose only relevant variables. + +--- + +# Planned Improvements + +## Chart Variables API + +Planned endpoint: + +```http +GET /api/charts/variables?module=climate +``` + +Expected response: + +```json +{ + "sensorId": 13, + "name": "Temperatura estufa 1", + "key": "temperatura.estufa.1", + "historianKey": "climate.temperatura.estufa.1", + "module": "climate", + "unit": "C", + "category": "CLIMATE" +} +``` + +--- + +## Derived Climate Values + +Future derived telemetry: + +* DPV +* absolute humidity +* dew point +* climate alarms +* sensor health + +--- + +## Workspace System + +Planned chart workspace support: + +* save layouts +* detachable charts +* multi-monitor support +* reusable chart presets +* draggable variables + +--- + +# Useful Development Commands + +## Import Sensors + +```bash +curl -X POST http://localhost:18450/api/sensor-definition-import/run +``` + +## Check Acquisition Plan + +```bash +curl http://localhost:18450/api/acquisition/plan +``` + +## Check Meteo Module + +```bash +curl http://localhost:18450/api/modules/meteo +``` + +## Check Climate Module + +```bash +curl http://localhost:18450/api/modules/climate +``` + +## Check Latest Telemetry + +```bash +curl http://localhost:18450/api/telemetry/latest +``` + +--- + +# Current Status + +The backend foundation is currently stable for: + +* sensor catalog import +* Modbus acquisition +* capability-based acquisition planning +* telemetry cache +* meteorology module +* climate module +* historical accumulation +* frontend integration + +Next major milestone: + +```txt +Chart Variables API + Derived Climate Telemetry +``` diff --git a/src/main/java/com/litoralregas/backend/acquisition/plan/AcquisitionPlanBuilder.java b/src/main/java/com/litoralregas/backend/acquisition/plan/AcquisitionPlanBuilder.java index 026a06a..00b2e84 100644 --- a/src/main/java/com/litoralregas/backend/acquisition/plan/AcquisitionPlanBuilder.java +++ b/src/main/java/com/litoralregas/backend/acquisition/plan/AcquisitionPlanBuilder.java @@ -77,21 +77,55 @@ public class AcquisitionPlanBuilder { ) { Integer address = sensor.getModbusAddress(); - if (address == null || address < 0) { + if (address == null) { return false; } + // Exterior/meteo climate block if (address >= 10 && address <= 22) { return true; } - if (address < 100 || address > 899) { - return false; + // Main greenhouse climate blocks: + // E1: 100-126 + // E2: 140-166 + // E3: 180-206 + // ... + // stride = 40 + if (address >= 100 && address <= 899) { + int greenhouseNumber = ((address - 100) / 40) + 1; + int offsetInBlock = (address - 100) % 40; + + return greenhouseNumber >= 1 + && greenhouseNumber <= greenhouseCount + && offsetInBlock >= 0 + && offsetInBlock <= 26; } - int greenhouseNumber = ((address - 100) / 40) + 1; + // Soil sensors: + // Humidade solo 1-36: 3200-3235 + // Temperatura solo 1-36: 3236-3271 + if (address >= 3200 && address <= 3271) { + return true; + } - return greenhouseNumber >= 1 && greenhouseNumber <= greenhouseCount; + // Computed climate values: + // DPV Estufa 1-20: -121 to -140 + // Hum. Absoluta Estufa 1-20: -141 to -160 + if (address >= -160 && address <= -121) { + int absoluteIndex = Math.abs(address); + + int greenhouseNumber; + if (absoluteIndex >= 121 && absoluteIndex <= 140) { + greenhouseNumber = absoluteIndex - 120; + } else { + greenhouseNumber = absoluteIndex - 140; + } + + return greenhouseNumber >= 1 && greenhouseNumber <= greenhouseCount; + } + + return false; } private boolean belongsToEnabledIrrigationRange( diff --git a/src/main/java/com/litoralregas/backend/modules/climate/ClimateModuleController.java b/src/main/java/com/litoralregas/backend/modules/climate/ClimateModuleController.java new file mode 100644 index 0000000..edfd453 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/modules/climate/ClimateModuleController.java @@ -0,0 +1,21 @@ +package com.litoralregas.backend.modules.climate; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/modules/climate") +public class ClimateModuleController { + + private final ClimateModuleService climateModuleService; + + public ClimateModuleController(ClimateModuleService climateModuleService) { + this.climateModuleService = climateModuleService; + } + + @GetMapping + public ClimateModuleResponse getLatest() { + return climateModuleService.getLatest(); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/modules/climate/ClimateModuleResponse.java b/src/main/java/com/litoralregas/backend/modules/climate/ClimateModuleResponse.java new file mode 100644 index 0000000..bfb6e78 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/modules/climate/ClimateModuleResponse.java @@ -0,0 +1,10 @@ +package com.litoralregas.backend.modules.climate; + +import com.litoralregas.backend.modules.shared.ModuleSensorResponse; +import java.time.Instant; +import java.util.List; + +public record ClimateModuleResponse( + Instant timestamp, + List sensors +) {} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/modules/climate/ClimateModuleService.java b/src/main/java/com/litoralregas/backend/modules/climate/ClimateModuleService.java new file mode 100644 index 0000000..c83bcfa --- /dev/null +++ b/src/main/java/com/litoralregas/backend/modules/climate/ClimateModuleService.java @@ -0,0 +1,118 @@ +package com.litoralregas.backend.modules.climate; + +import com.litoralregas.backend.acquisition.telemetry.TelemetryCache; +import com.litoralregas.backend.acquisition.telemetry.TelemetrySnapshot; +import com.litoralregas.backend.modules.shared.ModuleSensorResponse; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Comparator; +import java.util.List; + +@Service +public class ClimateModuleService { + + private final TelemetryCache telemetryCache; + + public ClimateModuleService(TelemetryCache telemetryCache) { + this.telemetryCache = telemetryCache; + } + + public ClimateModuleResponse getLatest() { + List sensors = telemetryCache.getAll() + .stream() + .filter(this::isClimateSensor) + .sorted(Comparator.comparing(TelemetrySnapshot::sensorId)) + .map(this::toResponse) + .toList(); + + return new ClimateModuleResponse( + Instant.now(), + sensors + ); + } + + private boolean isClimateSensor(TelemetrySnapshot snapshot) { + String name = normalize(snapshot.name()); + + boolean isIrrigationOrHydro = + name.contains(" ce") + || name.contains("ph") + || name.contains("bomba") + || name.contains("rega") + || name.contains("tanque") + || name.contains("hidro") + || name.contains("dren") + || name.contains("bancada") + || name.contains("pressao"); + + if (isIrrigationOrHydro) { + return false; + } + + return name.contains("greenhouse") + || name.contains("exterior") + || name.contains("interior") + || name.contains("clima") + || name.contains("estufa") + || name.contains("zenital") + || name.contains("lateral") + || name.contains("topo") + || name.contains("ecra") + || name.contains("dpv") + || name.contains("absoluta") + || name.startsWith("il ") + || name.contains(" il ") + || name.contains("ventilacao") + || name.contains("ventilador") + || name.contains("extrator") + || name.contains("janela") + || name.contains("iluminacao") + || name.contains("luz") + || name.contains("sombra") + || name.contains("cortina") + || name.contains("co2") + || name.contains("humidade solo") + || name.contains("temperatura solo") + || name.contains("temperatura do solo") + || name.contains("humidade do solo"); + } + + private ModuleSensorResponse toResponse(TelemetrySnapshot snapshot) { + return new ModuleSensorResponse( + snapshot.sensorId(), + snapshot.name(), + buildKey(snapshot.name()), + snapshot.value(), + snapshot.unit(), + snapshot.modbusAddress(), + snapshot.bitOffset(), + snapshot.timestamp() + ); + } + + private String buildKey(String name) { + return normalize(name) + .replaceAll("[^a-z0-9]+", ".") + .replaceAll("^\\.|\\.$", ""); + } + + private String normalize(String value) { + if (value == null) { + return ""; + } + + return value + .toLowerCase() + .replace("ç", "c") + .replace("ã", "a") + .replace("á", "a") + .replace("à", "a") + .replace("é", "e") + .replace("ê", "e") + .replace("í", "i") + .replace("ó", "o") + .replace("õ", "o") + .replace("ú", "u"); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/modules/climate/websocket/ClimateModuleWebSocketPublisher.java b/src/main/java/com/litoralregas/backend/modules/climate/websocket/ClimateModuleWebSocketPublisher.java new file mode 100644 index 0000000..54c9d00 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/modules/climate/websocket/ClimateModuleWebSocketPublisher.java @@ -0,0 +1,4 @@ +package com.litoralregas.backend.modules.climate.websocket; + +public class ClimateModuleWebSocketPublisher { +} diff --git a/src/main/java/com/litoralregas/backend/sensor/SensorDefinitionRepository.java b/src/main/java/com/litoralregas/backend/sensor/SensorDefinitionRepository.java index cafdf50..20631f6 100644 --- a/src/main/java/com/litoralregas/backend/sensor/SensorDefinitionRepository.java +++ b/src/main/java/com/litoralregas/backend/sensor/SensorDefinitionRepository.java @@ -1,8 +1,11 @@ package com.litoralregas.backend.sensor; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface SensorDefinitionRepository extends JpaRepository { @@ -11,4 +14,21 @@ public interface SensorDefinitionRepository extends JpaRepository findByEnabledTrueOrderByNameAsc(); List findAllByOrderByNameAsc(); + + @Query(""" + select s + from SensorDefinition s + where s.modbusAddress = :modbusAddress + and ( + (:bitOffset is null and s.bitOffset is null) + or s.bitOffset = :bitOffset + ) + order by s.id asc + """) + List findAllByHardwareAddress( + @Param("modbusAddress") Integer modbusAddress, + @Param("bitOffset") Integer bitOffset + ); + + Optional findByName(String name); } \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/sensor/importer/SensorDefinitionImportService.java b/src/main/java/com/litoralregas/backend/sensor/importer/SensorDefinitionImportService.java index 6e36984..cbda2e8 100644 --- a/src/main/java/com/litoralregas/backend/sensor/importer/SensorDefinitionImportService.java +++ b/src/main/java/com/litoralregas/backend/sensor/importer/SensorDefinitionImportService.java @@ -43,9 +43,29 @@ public class SensorDefinitionImportService { } SensorDefinitionImportRow row = parser.parseLine(line) - .orElseThrow(() -> new IllegalArgumentException("Invalid empty sensor row.")); + .orElseThrow(() -> new IllegalArgumentException( + "Invalid sensor row: " + line + )); - if (repository.existsByName(row.name())) { + List 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++; continue; } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index aa3f4ca..13e43a2 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -33,7 +33,7 @@ litoralregas: acquisition: scheduler: enabled: true - fixed-delay-millis: 3000 + fixed-delay-millis: 10000 # change here for longer wait between cycles weather: enabled: true @@ -41,6 +41,15 @@ litoralregas: longitude: -8.7375 location-name: Mira + modules: + climate: + enabled: true + exterior-enabled: true + enabled-sites: + - 1 + irrigation: + enabled: true + weather: api-key: 0aa355536b6c469eb4b82226262505 base-url: https://api.weatherapi.com/v1 diff --git a/src/main/resources/config/sensor-map.txt b/src/main/resources/config/sensor-map.txt index d38aaf5..448833c 100644 --- a/src/main/resources/config/sensor-map.txt +++ b/src/main/resources/config/sensor-map.txt @@ -81,8 +81,8 @@ Humidade 5 estufa 4*231*0*%*c CO2 estufa 4*232*0*ppm*c Temperatura do solo Estufa 4*233*1*C*c Humidade do solo Estufa 4*234*0*%*c -Ventiladores Estufa 5*235,0*0*SU*c -Extratores Estufa 5*235,1*0*SU*c +Ventiladores Estufa 4*235,0*0*SU*c +Extratores Estufa 4*235,1*0*SU*c Temperatura estufa 5*260*1*C*c Humidade estufa 5*261*0*%*c