Compare commits

...

20 Commits

Author SHA1 Message Date
litoral05 36b105c937 stable workspace version 2026-06-09 17:15:45 +01:00
litoral05 6ccef04914 Multi workspace config - still not finished 2026-06-09 15:35:26 +01:00
litoral05 6ef1e83e63 Add native LAN VNC streaming backend 2026-06-01 16:28:57 +01:00
litoral05 ba52c1516b Changes delay betweeen scheduler to fixed 3s 2026-06-01 12:08:28 +01:00
litoral05 109b226448 Fixes historian service for accumulated vars 2026-05-29 17:04:23 +01:00
litoral05 8138ecc1d2 Gets rid of currentWeather 2026-05-29 11:40:37 +01:00
litoral05 460e57bc3a creates empty workspace if none is created 2026-05-28 10:09:15 +01:00
litoral05 4659620072 adds chart workspace 2026-05-27 14:37:46 +01:00
litoral05 df673e3cb4 Refactor telemetry pipeline into metadata-driven architecture 2026-05-27 09:11:22 +01:00
litoral05 727278d644 Refactor telemetry registry to use sensor definitions .json and historian identity 2026-05-27 08:39:51 +01:00
litoral05 0a33f42502 Refactor telemetry acquisition and add dynamic climate module support 2026-05-26 10:22:57 +01:00
litoral05 6fefa2542e weather api support 2026-05-25 17:19:47 +01:00
litoral05 d86ec39b08 added historian indexes 2026-05-22 17:38:47 +01:00
litoral05 af709852ac Add accumulated analytics with radiation integration 2026-05-22 17:23:36 +01:00
litoral05 db75744305 Record meteo module sensors in historian 2026-05-22 13:47:39 +01:00
litoral05 cb63d6a237 Add meteo module API and websocket stream 2026-05-22 13:36:27 +01:00
litoral05 5e30160b31 Add dashboard historian persistence 2026-05-20 17:28:58 +01:00
litoral05 315f7b61f8 Added corsConfig + systemInfo controller 2026-05-20 12:23:23 +01:00
litoral05 809b79b6f6 Add live telemetry WebSocket broadcasting 2026-05-20 09:11:24 +01:00
litoral05 f3d08bd837 Add acquisition shceduler runtime status 2026-05-20 08:48:00 +01:00
84 changed files with 39239 additions and 2059 deletions
+538
View File
@@ -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
```
@@ -2,12 +2,13 @@ package com.litoralregas.backend;
import com.litoralregas.backend.acquisition.scheduler.AcquisitionSchedulerProperties;
import com.litoralregas.backend.modbus.ModbusConnectionProperties;
import com.litoralregas.backend.weather.WeatherApiProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@SpringBootApplication
@EnableConfigurationProperties({ModbusConnectionProperties.class, AcquisitionSchedulerProperties.class})
@EnableConfigurationProperties({ModbusConnectionProperties.class, AcquisitionSchedulerProperties.class, WeatherApiProperties.class})
public class BackendApplication {
public static void main(String[] args) {
@@ -74,9 +74,15 @@ public class BlockPollingService {
Integer rawValue = rawValueForSensor(sensor, block, result);
Object value = convertValue(sensor, rawValue);
if (value == null) {
continue;
}
telemetryCache.put(new TelemetrySnapshot(
sensor.getId(),
sensor.getKey(),
sensor.getName(),
sensor.getCategory(),
sensor.getModbusAddress(),
sensor.getBitOffset(),
rawValue,
@@ -121,21 +127,68 @@ public class BlockPollingService {
return result.values().get(offset);
}
private Object convertValue(SensorDefinition sensor, Integer rawValue) {
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.");
throw new IllegalStateException(
"BOOLEAN sensor requires bitOffset."
);
}
return ((rawValue >> bitOffset) & 1) == 1;
}
if (sensor.getValueType() == SensorValueType.DECIMAL) {
return rawValue / Math.pow(10, sensor.getDecimalPlaces());
int decodedRawValue = decodeSignedRawValue(
rawValue,
sensor.getSigned()
);
double scaledValue =
decodedRawValue * sensor.getScaleFactor();
if (!isWithinValidRange(sensor, scaledValue)) {
return null;
}
if (sensor.getValueType() == SensorValueType.INTEGER) {
return (int) Math.round(scaledValue);
}
return scaledValue;
}
private int decodeSignedRawValue(
Integer rawValue,
Boolean signed
) {
if (rawValue == null) {
return 0;
}
if (Boolean.TRUE.equals(signed) && rawValue > 32767) {
return rawValue - 65536;
}
return rawValue;
}
private boolean isWithinValidRange(
SensorDefinition sensor,
double value
) {
Double validMin = sensor.getValidMin();
Double validMax = sensor.getValidMax();
if (validMin != null && value < validMin) {
return false;
}
return validMax == null || value <= validMax;
}
}
@@ -77,23 +77,57 @@ 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;
}
// 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(
SensorDefinition sensor,
Integer controllerCount
@@ -0,0 +1,19 @@
package com.litoralregas.backend.acquisition.scheduler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class AcquisitionRuntimeController {
private final AcquisitionSchedulerService acquisitionSchedulerService;
public AcquisitionRuntimeController(AcquisitionSchedulerService acquisitionSchedulerService) {
this.acquisitionSchedulerService = acquisitionSchedulerService;
}
@GetMapping("/api/acquisition/runtime-status")
public AcquisitionRuntimeStatus getRuntimeStatus() {
return acquisitionSchedulerService.getRuntimeStatus();
}
}
@@ -8,7 +8,7 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
@Configuration
public class AcquisitionSchedulerConfig {
@Bean
@Bean(name = "acquisitionTaskScheduler")
public TaskScheduler acquisitionTaskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(1);
@@ -2,7 +2,19 @@ package com.litoralregas.backend.acquisition.scheduler;
import com.litoralregas.backend.acquisition.polling.AcquisitionPollResult;
import com.litoralregas.backend.acquisition.block.BlockPollingService;
import com.litoralregas.backend.dashboard.DashboardOverviewResponse;
import com.litoralregas.backend.dashboard.DashboardOverviewService;
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.websocket.MeteoModuleWebSocketPublisher;
import com.litoralregas.backend.websocket.dashboard.DashboardOverviewWebSocketPublisher;
import com.litoralregas.backend.websocket.telemetry.TelemetryWebSocketPublisher;
import com.litoralregas.backend.modules.meteo.MeteoModuleService;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.stereotype.Service;
@@ -15,19 +27,44 @@ public class AcquisitionSchedulerService {
private final BlockPollingService blockPollingService;
private final AcquisitionSchedulerProperties properties;
private final TaskScheduler taskScheduler;
private final TelemetryWebSocketPublisher telemetryWebSocketPublisher;
private final DashboardOverviewWebSocketPublisher dashboardOverviewWebSocketPublisher;
private final MeteoModuleWebSocketPublisher meteoModuleWebSocketPublisher;
private final DashboardOverviewService dashboardOverviewService;
private final MeteoModuleService meteoModuleService;
private final HistorianService historianService;
private final AcquisitionRuntimeStatus runtimeStatus = new AcquisitionRuntimeStatus();
private final AtomicBoolean polling = new AtomicBoolean(false);
private final ClimateModuleWebSocketPublisher climateModuleWebSocketPublisher;
private final ClimateModuleService climateModuleService;
public AcquisitionSchedulerService(
BlockPollingService blockPollingService,
AcquisitionSchedulerProperties properties,
TaskScheduler taskScheduler
@Qualifier("acquisitionTaskScheduler") TaskScheduler taskScheduler,
TelemetryWebSocketPublisher telemetryWebSocketPublisher,
DashboardOverviewWebSocketPublisher dashboardOverviewWebSocketPublisher,
MeteoModuleWebSocketPublisher meteoModuleWebSocketPublisher,
HistorianService historianService,
DashboardOverviewService dashboardOverviewService,
MeteoModuleService meteoModuleService,
ClimateModuleWebSocketPublisher climateModuleWebSocketPublisher,
ClimateModuleService climateModuleService
) {
this.blockPollingService = blockPollingService;
this.properties = properties;
this.taskScheduler = taskScheduler;
this.telemetryWebSocketPublisher = telemetryWebSocketPublisher;
this.dashboardOverviewWebSocketPublisher = dashboardOverviewWebSocketPublisher;
this.meteoModuleWebSocketPublisher = meteoModuleWebSocketPublisher;
this.historianService = historianService;
this.dashboardOverviewService = dashboardOverviewService;
this.meteoModuleService = meteoModuleService;
this.climateModuleWebSocketPublisher = climateModuleWebSocketPublisher;
this.climateModuleService = climateModuleService;
}
@PostConstruct
@@ -39,7 +76,7 @@ public class AcquisitionSchedulerService {
System.out.println("Starting acquisition scheduler.");
taskScheduler.scheduleWithFixedDelay(
taskScheduler.scheduleAtFixedRate(
this::safePoll,
properties.getFixedDelayMillis()
);
@@ -59,12 +96,52 @@ public class AcquisitionSchedulerService {
runtimeStatus.setLastStartedAt(Instant.now());
runtimeStatus.setLastError(null);
long started = System.currentTimeMillis();
try {
AcquisitionPollResult result = blockPollingService.pollOnceByBlocks();
System.out.println(
"pollOnceByBlocks took "
+ (System.currentTimeMillis() - started)
+ " ms"
);
long wsStart = System.currentTimeMillis();
runtimeStatus.setLastSuccessfulReads(result.successfulReads());
runtimeStatus.setLastFailedReads(result.failedReads());
telemetryWebSocketPublisher.publishLatestTelemetry();
System.out.println(
"publishLatestTelemetry took "
+ (System.currentTimeMillis() - wsStart)
+ " ms"
);
DashboardOverviewResponse overview =
dashboardOverviewService.getOverview();
dashboardOverviewWebSocketPublisher.publishOverview(overview);
MeteoModuleResponse meteo =
meteoModuleService.getLatest();
historianService.recordModuleSensors(
meteo.sensors(),
meteo.timestamp()
);
meteoModuleWebSocketPublisher.publishLatest(meteo);
ClimateModuleResponse climate =
climateModuleService.getLatest();
historianService.recordModuleSensors(
climate.sensors(),
climate.timestamp()
);
climateModuleWebSocketPublisher.publishLatest(climate);
} catch (Exception exception) {
runtimeStatus.setLastError(exception.getMessage());
@@ -1,7 +1,5 @@
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.ModbusReadResult;
import com.litoralregas.backend.modbus.ModbusUnit;
@@ -32,11 +30,19 @@ public class SensorTelemetryReader {
}
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) {
throw new IllegalArgumentException("Only MODBUS sensors can be read directly.");
throw new IllegalArgumentException(
"Only MODBUS sensors can be read directly."
);
}
ModbusReadResult result = modbusClient.readInputRegisters(
@@ -46,11 +52,20 @@ public class SensorTelemetryReader {
);
Integer rawValue = result.values().getFirst();
Object value = convertValue(sensorDefinition, rawValue);
if (value == null) {
throw new IllegalStateException(
"Sensor value is invalid: " + sensorDefinition.getKey()
);
}
TelemetrySnapshot snapshot = new TelemetrySnapshot(
sensorDefinition.getId(),
sensorDefinition.getKey(),
sensorDefinition.getName(),
sensorDefinition.getCategory(),
sensorDefinition.getModbusAddress(),
sensorDefinition.getBitOffset(),
rawValue,
@@ -64,21 +79,67 @@ public class SensorTelemetryReader {
return snapshot;
}
private Object convertValue(SensorDefinition sensorDefinition, Integer rawValue) {
private Object convertValue(
SensorDefinition sensorDefinition,
Integer rawValue
) {
if (sensorDefinition.getValueType() == SensorValueType.BOOLEAN) {
Integer bitOffset = sensorDefinition.getBitOffset();
if (bitOffset == null) {
throw new IllegalStateException("BOOLEAN sensor requires bitOffset.");
throw new IllegalStateException(
"BOOLEAN sensor requires bitOffset."
);
}
return ((rawValue >> bitOffset) & 1) == 1;
}
if (sensorDefinition.getValueType() == SensorValueType.DECIMAL) {
return rawValue / Math.pow(10, sensorDefinition.getDecimalPlaces());
int decodedRawValue = decodeSignedRawValue(
rawValue,
sensorDefinition.getSigned()
);
double scaledValue =
decodedRawValue * sensorDefinition.getScaleFactor();
if (!isWithinValidRange(sensorDefinition, scaledValue)) {
return null;
}
if (sensorDefinition.getValueType() == SensorValueType.INTEGER) {
return (int) Math.round(scaledValue);
}
return scaledValue;
}
private int decodeSignedRawValue(
Integer rawValue,
Boolean signed
) {
if (rawValue == null) {
return 0;
}
if (Boolean.TRUE.equals(signed) && rawValue > 32767) {
return rawValue - 65536;
}
return rawValue;
}
private boolean isWithinValidRange(
SensorDefinition sensorDefinition,
double value
) {
Double validMin = sensorDefinition.getValidMin();
Double validMax = sensorDefinition.getValidMax();
if (validMin != null && value < validMin) {
return false;
}
return validMax == null || value <= validMax;
}
}
@@ -4,7 +4,9 @@ import java.time.Instant;
public record TelemetrySnapshot(
Integer sensorId,
String key,
String name,
String category,
Integer modbusAddress,
Integer bitOffset,
Integer rawValue,
@@ -0,0 +1,132 @@
package com.litoralregas.backend.charts;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "chart_workspace")
public class ChartWorkspace {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ChartWorkspaceScope scope;
@Column(nullable = false, length = 120)
private String name;
@Column(name = "sort_order", nullable = false)
private Integer sortOrder = 0;
@Column(name = "is_default", nullable = false)
private Boolean defaultWorkspace = false;
@Column(name = "layout_mode", nullable = false)
private String layoutMode;
@Column(name = "charts_json", nullable = false, columnDefinition = "TEXT")
private String chartsJson;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
protected ChartWorkspace() {
}
public ChartWorkspace(
ChartWorkspaceScope scope,
String name,
String layoutMode,
String chartsJson
) {
this.scope = scope;
this.name = name;
this.layoutMode = layoutMode;
this.chartsJson = chartsJson;
}
@PrePersist
public void onCreate() {
Instant now = Instant.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
}
@PreUpdate
public void onUpdate() {
updatedAt = Instant.now();
}
public Integer getId() {
return id;
}
public ChartWorkspaceScope getScope() {
return scope;
}
public void setScope(ChartWorkspaceScope scope) {
this.scope = scope;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public Boolean getDefaultWorkspace() {
return defaultWorkspace;
}
public void setDefaultWorkspace(Boolean defaultWorkspace) {
this.defaultWorkspace = defaultWorkspace;
}
public String getLayoutMode() {
return layoutMode;
}
public void setLayoutMode(String layoutMode) {
this.layoutMode = layoutMode;
}
public String getChartsJson() {
return chartsJson;
}
public void setChartsJson(String chartsJson) {
this.chartsJson = chartsJson;
}
public Instant getCreatedAt() {
return createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
}
@@ -0,0 +1,88 @@
package com.litoralregas.backend.charts;
import com.litoralregas.backend.charts.dto.ChartWorkspaceRequest;
import com.litoralregas.backend.charts.dto.ChartWorkspaceResponse;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/chart-workspaces")
public class ChartWorkspaceController {
private final ChartWorkspaceService service;
public ChartWorkspaceController(
ChartWorkspaceService service
) {
this.service = service;
}
@GetMapping
public List<ChartWorkspaceResponse> listWorkspaces(
@RequestParam ChartWorkspaceScope scope
) {
return service.listWorkspaces(scope);
}
@PostMapping
public ChartWorkspaceResponse createWorkspace(
@RequestParam ChartWorkspaceScope scope,
@RequestBody ChartWorkspaceRequest request
) {
return service.createWorkspace(
scope,
request
);
}
@GetMapping("/id/{id}")
public ChartWorkspaceResponse getWorkspaceById(
@PathVariable Integer id
) {
return service.getWorkspaceById(id);
}
@PutMapping("/id/{id}")
public ChartWorkspaceResponse updateWorkspaceById(
@PathVariable Integer id,
@RequestBody ChartWorkspaceRequest request
) {
return service.updateWorkspace(
id,
request
);
}
@DeleteMapping("/id/{id}")
public void deleteWorkspaceById(
@PathVariable Integer id
) {
service.deleteWorkspace(id);
}
@GetMapping("/{scope}")
public ChartWorkspaceResponse getWorkspace(
@PathVariable ChartWorkspaceScope scope
) {
return service.getWorkspace(scope);
}
@PutMapping("/{scope}")
public ChartWorkspaceResponse saveWorkspace(
@PathVariable ChartWorkspaceScope scope,
@RequestBody ChartWorkspaceRequest request
) {
return service.saveWorkspace(
scope,
request
);
}
}
@@ -0,0 +1,22 @@
package com.litoralregas.backend.charts;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface ChartWorkspaceRepository
extends JpaRepository<ChartWorkspace, Integer> {
List<ChartWorkspace> findAllByScopeOrderBySortOrderAscIdAsc(
ChartWorkspaceScope scope
);
Optional<ChartWorkspace> findFirstByScopeAndDefaultWorkspaceTrue(
ChartWorkspaceScope scope
);
long countByScope(
ChartWorkspaceScope scope
);
}
@@ -0,0 +1,11 @@
package com.litoralregas.backend.charts;
public enum ChartWorkspaceScope {
GLOBAL,
CLIMATE,
IRRIGATION,
METEO,
LIGHTING,
HYDRO,
AEROPONICS
}
@@ -0,0 +1,361 @@
package com.litoralregas.backend.charts;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.litoralregas.backend.charts.dto.ChartWorkspaceRequest;
import com.litoralregas.backend.charts.dto.ChartWorkspaceResponse;
import jakarta.transaction.Transactional;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Objects;
@Service
public class ChartWorkspaceService {
private static final int MAX_WORKSPACES_PER_SCOPE = 10;
private static final int MAX_CHARTS_PER_WORKSPACE = 10;
private final ChartWorkspaceRepository repository;
private final ObjectMapper objectMapper;
public ChartWorkspaceService(
ChartWorkspaceRepository repository,
ObjectMapper objectMapper
) {
this.repository = repository;
this.objectMapper = objectMapper;
}
@Transactional
public ChartWorkspaceResponse saveWorkspace(
ChartWorkspaceScope scope,
ChartWorkspaceRequest request
) {
ChartWorkspace workspace =
repository.findFirstByScopeAndDefaultWorkspaceTrue(scope)
.orElseGet(() -> createDefaultWorkspace(scope));
applyRequest(workspace, request, true);
ChartWorkspace saved =
repository.save(workspace);
return toResponse(saved);
}
@Transactional
public ChartWorkspaceResponse getWorkspace(
ChartWorkspaceScope scope
) {
return toResponse(getOrCreateDefaultWorkspace(scope));
}
@Transactional
public List<ChartWorkspaceResponse> listWorkspaces(
ChartWorkspaceScope scope
) {
ensureDefaultWorkspace(scope);
return repository.findAllByScopeOrderBySortOrderAscIdAsc(scope)
.stream()
.map(this::toResponse)
.toList();
}
public ChartWorkspaceResponse getWorkspaceById(
Integer id
) {
return toResponse(requireWorkspace(id));
}
@Transactional
public ChartWorkspaceResponse createWorkspace(
ChartWorkspaceScope scope,
ChartWorkspaceRequest request
) {
if (repository.countByScope(scope) >= MAX_WORKSPACES_PER_SCOPE) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Maximum workspaces reached for this scope."
);
}
ChartWorkspace workspace =
new ChartWorkspace(
scope,
normalizeName(request.name(), "Novo Workspace"),
normalizeLayoutMode(request.layoutMode()),
normalizeChartsJson(request.chartsJson())
);
workspace.setSortOrder(
request.sortOrder() == null
? nextSortOrder(scope)
: request.sortOrder()
);
workspace.setDefaultWorkspace(false);
if (Boolean.TRUE.equals(request.defaultWorkspace())) {
setDefaultWorkspace(workspace);
}
ChartWorkspace saved =
repository.save(workspace);
return toResponse(saved);
}
@Transactional
public ChartWorkspaceResponse updateWorkspace(
Integer id,
ChartWorkspaceRequest request
) {
ChartWorkspace workspace =
requireWorkspace(id);
applyRequest(workspace, request, false);
ChartWorkspace saved =
repository.save(workspace);
return toResponse(saved);
}
@Transactional
public void deleteWorkspace(
Integer id
) {
ChartWorkspace workspace =
requireWorkspace(id);
repository.delete(workspace);
if (Boolean.TRUE.equals(workspace.getDefaultWorkspace())) {
if (TransactionSynchronizationManager.isActualTransactionActive()) {
repository.flush();
}
repository.findAllByScopeOrderBySortOrderAscIdAsc(workspace.getScope())
.stream()
.findFirst()
.ifPresent(this::setDefaultWorkspace);
}
}
private ChartWorkspace getOrCreateDefaultWorkspace(
ChartWorkspaceScope scope
) {
return repository.findFirstByScopeAndDefaultWorkspaceTrue(scope)
.orElseGet(() ->
repository.findAllByScopeOrderBySortOrderAscIdAsc(scope)
.stream()
.findFirst()
.map(workspace -> {
setDefaultWorkspace(workspace);
return repository.save(workspace);
})
.orElseGet(() ->
repository.save(createDefaultWorkspace(scope))
)
);
}
private void ensureDefaultWorkspace(
ChartWorkspaceScope scope
) {
getOrCreateDefaultWorkspace(scope);
}
private ChartWorkspace createDefaultWorkspace(
ChartWorkspaceScope scope
) {
ChartWorkspace workspace =
new ChartWorkspace(
scope,
"Workspace principal",
"fourGrid",
"[]"
);
workspace.setDefaultWorkspace(true);
workspace.setSortOrder(0);
return workspace;
}
private ChartWorkspace requireWorkspace(
Integer id
) {
return repository.findById(id)
.orElseThrow(() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND,
"Workspace not found."
)
);
}
private void applyRequest(
ChartWorkspace workspace,
ChartWorkspaceRequest request,
boolean legacyDefaultUpdate
) {
if (request.name() != null || legacyDefaultUpdate) {
workspace.setName(
normalizeName(request.name(), workspace.getName())
);
}
if (request.sortOrder() != null) {
workspace.setSortOrder(request.sortOrder());
}
if (Boolean.TRUE.equals(request.defaultWorkspace())) {
setDefaultWorkspace(workspace);
} else if (
Boolean.FALSE.equals(request.defaultWorkspace()) &&
!Boolean.TRUE.equals(workspace.getDefaultWorkspace())
) {
workspace.setDefaultWorkspace(false);
}
if (request.layoutMode() != null) {
workspace.setLayoutMode(
normalizeLayoutMode(request.layoutMode())
);
}
if (request.chartsJson() != null) {
workspace.setChartsJson(
normalizeChartsJson(request.chartsJson())
);
}
}
private void setDefaultWorkspace(
ChartWorkspace workspace
) {
List<ChartWorkspace> workspaces =
repository.findAllByScopeOrderBySortOrderAscIdAsc(workspace.getScope());
boolean changedCurrentDefault = false;
for (ChartWorkspace candidate : workspaces) {
if (!Objects.equals(candidate.getId(), workspace.getId())) {
changedCurrentDefault =
changedCurrentDefault ||
Boolean.TRUE.equals(candidate.getDefaultWorkspace());
candidate.setDefaultWorkspace(false);
}
}
if (changedCurrentDefault && TransactionSynchronizationManager.isActualTransactionActive()) {
repository.flush();
}
workspace.setDefaultWorkspace(true);
}
private int nextSortOrder(
ChartWorkspaceScope scope
) {
return repository.findAllByScopeOrderBySortOrderAscIdAsc(scope)
.stream()
.map(ChartWorkspace::getSortOrder)
.filter(value -> value != null)
.max(Integer::compareTo)
.orElse(-1) + 1;
}
private String normalizeName(
String name,
String fallback
) {
String normalized =
name == null ? "" : name.trim();
if (normalized.isBlank()) {
return fallback == null || fallback.isBlank()
? "Workspace"
: fallback;
}
if (normalized.length() > 120) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Workspace name is too long."
);
}
return normalized;
}
private String normalizeLayoutMode(
String layoutMode
) {
if (layoutMode == null || layoutMode.isBlank()) {
return "fourGrid";
}
return layoutMode;
}
private String normalizeChartsJson(
String chartsJson
) {
String normalized =
chartsJson == null || chartsJson.isBlank()
? "[]"
: chartsJson;
try {
JsonNode root =
objectMapper.readTree(normalized);
if (!root.isArray()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"chartsJson must be an array."
);
}
if (root.size() > MAX_CHARTS_PER_WORKSPACE) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Maximum charts reached for this workspace."
);
}
} catch (ResponseStatusException exception) {
throw exception;
} catch (Exception exception) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"chartsJson is invalid."
);
}
return normalized;
}
private ChartWorkspaceResponse toResponse(
ChartWorkspace workspace
) {
return new ChartWorkspaceResponse(
workspace.getId(),
workspace.getScope(),
workspace.getName(),
workspace.getSortOrder(),
workspace.getDefaultWorkspace(),
workspace.getLayoutMode(),
workspace.getChartsJson(),
workspace.getCreatedAt(),
workspace.getUpdatedAt()
);
}
}
@@ -0,0 +1,10 @@
package com.litoralregas.backend.charts.dto;
public record ChartWorkspaceRequest(
String name,
Integer sortOrder,
Boolean defaultWorkspace,
String layoutMode,
String chartsJson
) {
}
@@ -0,0 +1,18 @@
package com.litoralregas.backend.charts.dto;
import com.litoralregas.backend.charts.ChartWorkspaceScope;
import java.time.Instant;
public record ChartWorkspaceResponse(
Integer id,
ChartWorkspaceScope scope,
String name,
Integer sortOrder,
Boolean defaultWorkspace,
String layoutMode,
String chartsJson,
Instant createdAt,
Instant updatedAt
) {
}
@@ -0,0 +1,23 @@
package com.litoralregas.backend.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:1420")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*");
}
};
}
}
@@ -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,11 @@
package com.litoralregas.backend.historian;
import java.time.Instant;
public record HistorianAccumulatedBucket(
String label,
Instant from,
Instant to,
Double total,
String unit
) {}
@@ -0,0 +1,75 @@
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);
}
@GetMapping("/api/historian/accumulated")
public List<HistorianAccumulatedBucket> getAccumulated(
@RequestParam String key,
@RequestParam String range
) {
Instant to = Instant.now();
Instant from;
String bucket = "day";
switch (range) {
case "30d" -> from = to.minus(java.time.Duration.ofDays(30));
case "month" -> {
java.time.ZonedDateTime now = java.time.ZonedDateTime.now(java.time.ZoneId.of("Europe/Lisbon"));
from = now.withDayOfMonth(1).truncatedTo(java.time.temporal.ChronoUnit.DAYS).toInstant();
}
case "year" -> {
java.time.ZonedDateTime now = java.time.ZonedDateTime.now(java.time.ZoneId.of("Europe/Lisbon"));
from = now.withDayOfYear(1).truncatedTo(java.time.temporal.ChronoUnit.DAYS).toInstant();
bucket = "month";
}
default -> from = to.minus(java.time.Duration.ofDays(7));
}
return historianService.getAccumulated(key, from, to, bucket);
}
}
@@ -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,259 @@
package com.litoralregas.backend.historian;
import com.litoralregas.backend.modules.shared.ModuleSensorResponse;
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 final HistorianSampleRepository historianSampleRepository;
private static final String SOURCE_MODULE = "MODULE";
public HistorianService(HistorianSampleRepository historianSampleRepository) {
this.historianSampleRepository = historianSampleRepository;
}
@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_MODULE);
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_MODULE);
historianSampleRepository.save(sample);
}
private HistorianSeriesPoint toSeriesPoint(HistorianSample sample) {
return new HistorianSeriesPoint(
sample.getSampledAt(),
sample.getNumericValue(),
sample.getBooleanValue(),
sample.getTextValue()
);
}
@Transactional
public void recordModuleSensors(
List<ModuleSensorResponse> sensors,
Instant sampledAt
) {
for (ModuleSensorResponse sensor : sensors) {
String key = sensor.key();
Object value = sensor.value();
if (value instanceof Number numberValue) {
recordNumber(
sampledAt,
key,
numberValue,
sensor.unit()
);
} else if (value instanceof Boolean booleanValue) {
recordBoolean(
sampledAt,
key,
booleanValue
);
}
}
}
@Transactional(readOnly = true)
public List<HistorianAccumulatedBucket> getAccumulated(
String keyName,
Instant from,
Instant to,
String bucket
) {
List<HistorianSample> samples = historianSampleRepository
.findByKeyNameAndSampledAtBetweenOrderBySampledAtAsc(keyName, from, to);
if (samples.isEmpty()) return List.of();
Map<Instant, List<HistorianSample>> grouped = samples.stream()
.collect(java.util.stream.Collectors.groupingBy(
sample -> bucketStart(sample.getSampledAt(), bucket),
LinkedHashMap::new,
java.util.stream.Collectors.toList()
));
return grouped.entrySet().stream()
.map(entry -> {
Instant bucketFrom = entry.getKey();
Instant bucketTo = bucketEnd(bucketFrom, bucket);
List<HistorianSample> bucketSamples = entry.getValue();
String unit;
double total;
if (isRadiationKey(keyName)) {
total = integrateWhPerSquareMeter(bucketSamples);
unit = "Wh/m²";
} else {
total = bucketSamples.stream()
.map(HistorianSample::getNumericValue)
.filter(java.util.Objects::nonNull)
.mapToDouble(Double::doubleValue)
.sum();
unit = bucketSamples.stream()
.map(HistorianSample::getUnit)
.filter(java.util.Objects::nonNull)
.findFirst()
.orElse(null);
}
return new HistorianAccumulatedBucket(
bucketLabel(bucketFrom, bucket),
bucketFrom,
bucketTo,
total,
unit
);
})
.toList();
}
private boolean isRadiationKey(String keyName) {
String normalized = keyName.toLowerCase();
return normalized.contains("radiacao")
|| normalized.contains("radiação")
|| normalized.contains("radiation")
|| normalized.equals("climate.sensor_16");
}
private double integrateWhPerSquareMeter(List<HistorianSample> samples) {
if (samples.size() < 2) return 0.0;
double total = 0.0;
for (int i = 1; i < samples.size(); i++) {
HistorianSample previous = samples.get(i - 1);
HistorianSample current = samples.get(i);
if (previous.getNumericValue() == null || current.getNumericValue() == null) {
continue;
}
double previousValue = previous.getNumericValue();
double currentValue = current.getNumericValue();
if (previousValue < 0 || currentValue < 0 || previousValue > 1400 || currentValue > 1400) {
continue;
}
double elapsedHours =
java.time.Duration.between(
previous.getSampledAt(),
current.getSampledAt()
).toMillis() / 1000.0 / 60.0 / 60.0;
if (elapsedHours <= 0 || elapsedHours > 0.25) {
continue;
}
double averageWm2 = (previousValue + currentValue) / 2.0;
total += averageWm2 * elapsedHours;
}
return total;
}
private Instant bucketStart(Instant instant, String bucket) {
java.time.ZonedDateTime date = instant.atZone(java.time.ZoneId.of("Europe/Lisbon"));
return switch (bucket) {
case "month" -> date
.withDayOfMonth(1)
.truncatedTo(java.time.temporal.ChronoUnit.DAYS)
.toInstant();
case "year" -> date
.withDayOfYear(1)
.truncatedTo(java.time.temporal.ChronoUnit.DAYS)
.toInstant();
default -> date
.truncatedTo(java.time.temporal.ChronoUnit.DAYS)
.toInstant();
};
}
private Instant bucketEnd(Instant bucketFrom, String bucket) {
java.time.ZonedDateTime date = bucketFrom.atZone(java.time.ZoneId.of("Europe/Lisbon"));
return switch (bucket) {
case "month" -> date.plusMonths(1).toInstant();
case "year" -> date.plusYears(1).toInstant();
default -> date.plusDays(1).toInstant();
};
}
private String bucketLabel(Instant bucketFrom, String bucket) {
java.time.ZonedDateTime date = bucketFrom.atZone(java.time.ZoneId.of("Europe/Lisbon"));
return switch (bucket) {
case "month" -> date.format(java.time.format.DateTimeFormatter.ofPattern("MM/yyyy"));
case "year" -> String.valueOf(date.getYear());
default -> date.format(java.time.format.DateTimeFormatter.ofPattern("dd/MM"));
};
}
}
@@ -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();
}
}
@@ -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<ModuleSensorResponse> sensors
) {}
@@ -0,0 +1,119 @@
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<ModuleSensorResponse> 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.key(),
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");
}
}
@@ -0,0 +1,24 @@
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 {
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);
}
}
@@ -0,0 +1,19 @@
package com.litoralregas.backend.modules.meteo;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MeteoModuleController {
private final MeteoModuleService meteoModuleService;
public MeteoModuleController(MeteoModuleService meteoModuleService) {
this.meteoModuleService = meteoModuleService;
}
@GetMapping("/api/modules/meteo")
public MeteoModuleResponse getLatest() {
return meteoModuleService.getLatest();
}
}
@@ -0,0 +1,13 @@
package com.litoralregas.backend.modules.meteo;
import com.litoralregas.backend.modules.shared.ModuleSensorResponse;
import java.time.Instant;
import java.util.List;
public record MeteoModuleResponse(
Instant timestamp,
Integer sensorCount,
List<ModuleSensorResponse> sensors
) {
}
@@ -0,0 +1,84 @@
package com.litoralregas.backend.modules.meteo;
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 MeteoModuleService {
private final TelemetryCache telemetryCache;
public MeteoModuleService(TelemetryCache telemetryCache) {
this.telemetryCache = telemetryCache;
}
public MeteoModuleResponse getLatest() {
List<ModuleSensorResponse> sensors = telemetryCache.getAll()
.stream()
.filter(this::isMeteoSensor)
.sorted(Comparator.comparing(TelemetrySnapshot::sensorId))
.map(this::toResponse)
.toList();
return new MeteoModuleResponse(
Instant.now(),
sensors.size(),
sensors
);
}
private boolean isMeteoSensor(TelemetrySnapshot snapshot) {
String name = normalize(snapshot.name());
return name.contains("exterior")
|| name.contains("vento")
|| name.contains("radiacao")
|| name.contains("co")
|| name.contains("chuva");
}
private ModuleSensorResponse toResponse(TelemetrySnapshot snapshot) {
return new ModuleSensorResponse(
snapshot.sensorId(),
snapshot.key(),
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");
}
}
@@ -0,0 +1,23 @@
package com.litoralregas.backend.modules.meteo.websocket;
import com.litoralregas.backend.modules.meteo.MeteoModuleResponse;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
@Service
public class MeteoModuleWebSocketPublisher {
private static final String DESTINATION = "/topic/modules/meteo/latest";
private final SimpMessagingTemplate messagingTemplate;
public MeteoModuleWebSocketPublisher(
SimpMessagingTemplate messagingTemplate
) {
this.messagingTemplate = messagingTemplate;
}
public void publishLatest(MeteoModuleResponse response) {
messagingTemplate.convertAndSend(DESTINATION, response);
}
}
@@ -0,0 +1,16 @@
package com.litoralregas.backend.modules.shared;
import java.time.Instant;
public record ModuleSensorResponse(
Integer sensorId,
String key,
String name,
String category,
Object value,
String unit,
Integer modbusAddress,
Integer bitOffset,
Instant timestamp
) {
}
@@ -1,6 +1,7 @@
package com.litoralregas.backend.sensor;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@@ -11,10 +12,16 @@ public class SensorDefinition {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(unique = true, nullable = false)
private String key;
@Column(nullable = false)
private String name;
@Column(name = "modbus_address", nullable = false)
@Column(nullable = false)
private String category;
@Column(name = "modbus_address")
private Integer modbusAddress;
@Column(name = "bit_offset")
@@ -29,9 +36,6 @@ public class SensorDefinition {
@Column(name = "decimal_places", nullable = false)
private Integer decimalPlaces;
@Column(nullable = false)
private String category;
@Enumerated(EnumType.STRING)
@Column(name = "source_type", nullable = false)
private SensorSourceType sourceType;
@@ -45,38 +49,75 @@ public class SensorDefinition {
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "scale_factor", nullable = false)
private Double scaleFactor;
@Column(name = "signed", nullable = false)
private Boolean signed;
@Column(name = "valid_min")
private Double validMin;
@Column(name = "valid_max")
private Double validMax;
protected SensorDefinition() {
}
public SensorDefinition(
String key,
String name,
String category,
Integer modbusAddress,
Integer bitOffset,
SensorValueType valueType,
String unit,
Integer decimalPlaces,
String category,
SensorSourceType sourceType,
Integer pollingIntervalSeconds,
Boolean enabled
Boolean enabled,
Double scaleFactor,
Boolean signed,
Double validMin,
Double validMax
) {
this.key = key;
this.name = name;
this.category = category;
this.modbusAddress = modbusAddress;
this.bitOffset = bitOffset;
this.valueType = valueType;
this.unit = unit;
this.decimalPlaces = decimalPlaces;
this.category = category;
this.sourceType = sourceType;
this.pollingIntervalSeconds = pollingIntervalSeconds;
this.enabled = enabled;
this.createdAt = Instant.now();
this.scaleFactor = scaleFactor != null
? scaleFactor
: Math.pow(10, -decimalPlaces);
this.signed = signed != null
? signed
: false;
this.validMin = validMin;
this.validMax = validMax;
}
public Integer getId() {
return id;
}
public String getKey() {
return key;
}
public void setKey(String key) {
this.key = key;
}
public String getName() {
return name;
}
@@ -85,6 +126,14 @@ public class SensorDefinition {
this.name = name;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public Integer getModbusAddress() {
return modbusAddress;
}
@@ -125,14 +174,6 @@ public class SensorDefinition {
this.decimalPlaces = decimalPlaces;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public SensorSourceType getSourceType() {
return sourceType;
}
@@ -164,4 +205,36 @@ public class SensorDefinition {
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public Double getScaleFactor(){
return scaleFactor;
}
public void setScaleFactor(Double scaleFactor) {
this.scaleFactor = scaleFactor;
}
public Boolean getSigned() {
return signed;
}
public void setSigned(Boolean signed) {
this.signed = signed;
}
public Double getValidMin() {
return validMin;
}
public void setValidMin(Double validMin) {
this.validMin = validMin;
}
public Double getValidMax() {
return validMax;
}
public void setValidMax(Double validMax) {
this.validMax = validMax;
}
}
@@ -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<SensorDefinition, Integer> {
@@ -11,4 +14,21 @@ public interface SensorDefinitionRepository extends JpaRepository<SensorDefiniti
List<SensorDefinition> findByEnabledTrueOrderByNameAsc();
List<SensorDefinition> 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<SensorDefinition> findAllByHardwareAddress(
@Param("modbusAddress") Integer modbusAddress,
@Param("bitOffset") Integer bitOffset
);
Optional<SensorDefinition> findByName(String name);
}
@@ -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,18 @@
package com.litoralregas.backend.sensor.importer;
public record SensorDefinitionConfig(
String key,
String name,
String category,
ModbusConfig modbus,
String valueType,
String unit,
Integer decimalPlaces,
Double scaleFactor,
Boolean signed,
Double validMin,
Double validMax,
Integer pollingIntervalSeconds,
Boolean enabled
) {
}
@@ -15,6 +15,6 @@ public class SensorDefinitionImportController {
@PostMapping("/api/sensor-definition-import/run")
public SensorDefinitionImportResult runImport() {
return importService.importSensorMap();
return importService.importSensorDefinitions();
}
}
@@ -1,93 +1,109 @@
package com.litoralregas.backend.sensor.importer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.litoralregas.backend.sensor.SensorDefinition;
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.SensorDefinitionImportRow;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.io.InputStream;
@Service
public class SensorDefinitionImportService {
private static final String SENSOR_MAP_PATH = "config/sensor-map.txt";
private static final int DEFAULT_POLLING_INTERVAL_SECONDS = 2;
private static final String SENSOR_DEFINITIONS_PATH =
"config/sensor-definitions.json";
private final SensorDefinitionMapParser parser;
private final SensorDefinitionRepository repository;
private final ObjectMapper objectMapper;
public SensorDefinitionImportService(
SensorDefinitionMapParser parser,
SensorDefinitionRepository repository
SensorDefinitionRepository repository,
ObjectMapper objectMapper
) {
this.parser = parser;
this.repository = repository;
this.objectMapper = objectMapper;
}
@Transactional
public SensorDefinitionImportResult importSensorMap() {
List<String> lines = readSensorMapLines();
public SensorDefinitionImportResult importSensorDefinitions() {
SensorDefinitionsFile file = readDefinitionsFile();
int imported = 0;
int skippedExisting = 0;
int skippedBlank = 0;
for (String line : lines) {
if (line == null || line.isBlank()) {
skippedBlank++;
continue;
}
for (SensorDefinitionConfig config : file.sensors()) {
SensorDefinitionImportRow row = parser.parseLine(line)
.orElseThrow(() -> new IllegalArgumentException("Invalid empty sensor row."));
if (repository.existsByName(row.name())) {
if (repository.findByName(config.name()).isPresent()) {
skippedExisting++;
continue;
}
SensorDefinition sensorDefinition = new SensorDefinition(
row.name(),
row.modbusAddress(),
row.bitOffset(),
row.valueType(),
row.unit(),
row.decimalPlaces(),
row.category(),
row.sourceType(),
DEFAULT_POLLING_INTERVAL_SECONDS,
true
config.key(),
config.name(),
config.category(),
config.modbus().address(),
config.modbus().bitOffset(),
SensorValueType.valueOf(config.valueType()),
config.unit(),
config.decimalPlaces(),
SensorSourceType.MODBUS,
config.pollingIntervalSeconds(),
config.enabled(),
config.scaleFactor(),
config.signed(),
config.validMin(),
config.validMax()
);
repository.save(sensorDefinition);
imported++;
}
return new SensorDefinitionImportResult(
lines.size(),
file.sensorCount(),
imported,
skippedExisting,
skippedBlank
0
);
}
private List<String> readSensorMapLines() {
private SensorDefinitionsFile readDefinitionsFile() {
try {
ClassPathResource resource = new ClassPathResource(SENSOR_MAP_PATH);
ClassPathResource resource =
new ClassPathResource(SENSOR_DEFINITIONS_PATH);
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) {
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
) {
}
@@ -0,0 +1,9 @@
package com.litoralregas.backend.system;
public record RuntimeConfigResponse(
String mode,
String controllerName,
String controllerIp,
Integer backendPort
) {
}
@@ -0,0 +1,32 @@
package com.litoralregas.backend.system;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/system")
public class SystemRuntimeController {
@Value("${litoralregas.modbus.host}")
private String controllerIp;
@Value("${server.port}")
private Integer backendPort;
@Value("${litoralregas.runtime.mode}")
private String mode;
@Value("${litoralregas.runtime.controller-name}")
private String controllerName;
@GetMapping("/runtime-config")
public RuntimeConfigResponse getRuntimeConfig() {
return new RuntimeConfigResponse(
mode,
controllerName,
controllerIp,
backendPort
);
}
}
@@ -0,0 +1,38 @@
package com.litoralregas.backend.vnc.rfb;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
public class DesCipherDesktop {
private final Cipher encryptCipher;
public DesCipherDesktop(byte[] keyBytes) throws Exception {
byte[] material = new byte[8];
System.arraycopy(keyBytes, 0, material, 0, Math.min(keyBytes.length, 8));
for (int i = 0; i < material.length; i++) {
material[i] = reverseBits(material[i]);
}
SecretKey key = new SecretKeySpec(material, "DES");
encryptCipher = Cipher.getInstance("DES/ECB/NoPadding");
encryptCipher.init(Cipher.ENCRYPT_MODE, key);
}
public void encrypt(byte[] src, int srcOff, byte[] dst, int dstOff) throws Exception {
byte[] out = encryptCipher.doFinal(src, srcOff, 8);
System.arraycopy(out, 0, dst, dstOff, 8);
}
private byte reverseBits(byte b) {
int v = b & 0xFF;
int r = 0;
for (int i = 0; i < 8; i++) {
r = (r << 1) | (v & 1);
v >>= 1;
}
return (byte) r;
}
}
@@ -0,0 +1,294 @@
package com.litoralregas.backend.vnc.rfb;
import java.util.Arrays;
public class DesktopBitmapData {
private int framebufferWidth;
private int framebufferHeight;
private byte[] pixelBuffer;
private byte[] copyBuffer;
private byte[] rowTemplateBuffer;
private static final int MAX_DIRTY_RECTS = 64;
private boolean dirty = true;
private int dirtyRectCount = 0;
private final int[] dirtyXs = new int[MAX_DIRTY_RECTS];
private final int[] dirtyYs = new int[MAX_DIRTY_RECTS];
private final int[] dirtyWs = new int[MAX_DIRTY_RECTS];
private final int[] dirtyHs = new int[MAX_DIRTY_RECTS];
private int dirtyMinX;
private int dirtyMinY;
private int dirtyMaxX;
private int dirtyMaxY;
public DesktopBitmapData(int width, int height) {
resize(width, height);
}
public void resize(int width, int height) {
this.framebufferWidth = Math.max(1, width);
this.framebufferHeight = Math.max(1, height);
this.pixelBuffer = new byte[framebufferWidth * framebufferHeight * 4];
this.copyBuffer = null;
this.rowTemplateBuffer = null;
for (int i = 3; i < pixelBuffer.length; i += 4) {
pixelBuffer[i] = (byte) 255;
}
this.dirty = false;
markDirty(0, 0, framebufferWidth, framebufferHeight);
}
public int getFramebufferWidth() {
return framebufferWidth;
}
public int getFramebufferHeight() {
return framebufferHeight;
}
public byte[] getPixelBuffer() {
return pixelBuffer;
}
public byte[] copyPixelBuffer() {
return Arrays.copyOf(pixelBuffer, pixelBuffer.length);
}
public boolean isDirty() {
return dirty;
}
public int getDirtyRectCount() {
return dirtyRectCount;
}
public int[] getDirtyXs() {
return dirtyXs;
}
public int[] getDirtyYs() {
return dirtyYs;
}
public int[] getDirtyWs() {
return dirtyWs;
}
public int[] getDirtyHs() {
return dirtyHs;
}
public void clearDirty() {
dirty = false;
dirtyRectCount = 0;
dirtyMinX = 0;
dirtyMinY = 0;
dirtyMaxX = 0;
dirtyMaxY = 0;
}
public boolean validDraw(int x, int y, int w, int h) {
return w > 0
&& h > 0
&& x >= 0
&& y >= 0
&& x + w <= framebufferWidth
&& y + h <= framebufferHeight;
}
public int offset(int x, int y) {
return (y * framebufferWidth + x) * 4;
}
private void markDirty(int x, int y, int w, int h) {
if (w <= 0 || h <= 0) return;
int minX = Math.max(0, x);
int minY = Math.max(0, y);
int maxX = Math.min(framebufferWidth, x + w);
int maxY = Math.min(framebufferHeight, y + h);
if (minX >= maxX || minY >= maxY) return;
if (!dirty) {
dirty = true;
dirtyRectCount = 0;
dirtyMinX = minX;
dirtyMinY = minY;
dirtyMaxX = maxX;
dirtyMaxY = maxY;
} else {
dirtyMinX = Math.min(dirtyMinX, minX);
dirtyMinY = Math.min(dirtyMinY, minY);
dirtyMaxX = Math.max(dirtyMaxX, maxX);
dirtyMaxY = Math.max(dirtyMaxY, maxY);
}
if (dirtyRectCount >= 0 && dirtyRectCount < MAX_DIRTY_RECTS) {
dirtyXs[dirtyRectCount] = minX;
dirtyYs[dirtyRectCount] = minY;
dirtyWs[dirtyRectCount] = maxX - minX;
dirtyHs[dirtyRectCount] = maxY - minY;
dirtyRectCount++;
} else {
dirtyRectCount = -1;
}
}
public void fillRect(int x, int y, int w, int h, int color) {
if (w <= 0 || h <= 0) return;
int startX = Math.max(0, x);
int startY = Math.max(0, y);
int endX = Math.min(framebufferWidth, x + w);
int endY = Math.min(framebufferHeight, y + h);
if (startX >= endX || startY >= endY) return;
int clippedW = endX - startX;
int clippedH = endY - startY;
int rowBytes = clippedW * 4;
if (rowTemplateBuffer == null || rowTemplateBuffer.length < rowBytes) {
rowTemplateBuffer = new byte[rowBytes];
}
byte b = (byte) (color & 0xFF);
byte g = (byte) ((color >> 8) & 0xFF);
byte r = (byte) ((color >> 16) & 0xFF);
byte a = (byte) 255;
for (int i = 0; i < rowBytes; i += 4) {
rowTemplateBuffer[i] = b;
rowTemplateBuffer[i + 1] = g;
rowTemplateBuffer[i + 2] = r;
rowTemplateBuffer[i + 3] = a;
}
for (int yy = startY; yy < endY; yy++) {
System.arraycopy(
rowTemplateBuffer,
0,
pixelBuffer,
offset(startX, yy),
rowBytes
);
}
markDirty(startX, startY, clippedW, clippedH);
}
public void copyRect(int srcX, int srcY, int dstX, int dstY, int w, int h) {
if (w <= 0 || h <= 0) return;
if (!validDraw(srcX, srcY, w, h)) return;
if (!validDraw(dstX, dstY, w, h)) return;
int rowBytes = w * 4;
boolean overlap =
dstY < srcY + h &&
srcY < dstY + h &&
dstX < srcX + w &&
srcX < dstX + w;
if (!overlap) {
for (int row = 0; row < h; row++) {
System.arraycopy(
pixelBuffer,
offset(srcX, srcY + row),
pixelBuffer,
offset(dstX, dstY + row),
rowBytes
);
}
markDirty(dstX, dstY, w, h);
return;
}
int required = rowBytes * h;
if (copyBuffer == null || copyBuffer.length < required) {
copyBuffer = new byte[required];
}
for (int row = 0; row < h; row++) {
System.arraycopy(
pixelBuffer,
offset(srcX, srcY + row),
copyBuffer,
row * rowBytes,
rowBytes
);
}
for (int row = 0; row < h; row++) {
System.arraycopy(
copyBuffer,
row * rowBytes,
pixelBuffer,
offset(dstX, dstY + row),
rowBytes
);
}
markDirty(dstX, dstY, w, h);
}
public void setRgbPixels(
int x,
int y,
int w,
int h,
int[] src,
int srcOffset,
int srcStride
) {
if (src == null || w <= 0 || h <= 0) return;
int startX = Math.max(0, x);
int startY = Math.max(0, y);
int endX = Math.min(framebufferWidth, x + w);
int endY = Math.min(framebufferHeight, y + h);
if (startX >= endX || startY >= endY) return;
int clippedW = endX - startX;
int clippedH = endY - startY;
int srcXOffset = startX - x;
int srcYOffset = startY - y;
for (int row = 0; row < clippedH; row++) {
int srcIndex = srcOffset + (srcYOffset + row) * srcStride + srcXOffset;
int dstIndex = offset(startX, startY + row);
for (int col = 0; col < clippedW; col++) {
int color = src[srcIndex++];
pixelBuffer[dstIndex++] = (byte) (color & 0xFF);
pixelBuffer[dstIndex++] = (byte) ((color >> 8) & 0xFF);
pixelBuffer[dstIndex++] = (byte) ((color >> 16) & 0xFF);
pixelBuffer[dstIndex++] = (byte) 255;
}
}
markDirty(startX, startY, clippedW, clippedH);
}
public void markFullDirty() {
markDirty(0, 0, framebufferWidth, framebufferHeight);
}
public void markSingleDirtyRect(int x, int y, int w, int h) {
markDirty(x, y, w, h);
}
}
@@ -0,0 +1,56 @@
package com.litoralregas.backend.vnc.rfb;
public abstract class InStreamDesktop {
protected byte[] b;
protected int ptr;
protected int end;
public byte[] getbuf() { return b; }
public int getptr() { return ptr; }
public int getend() { return end; }
public void setptr(int p) { ptr = p; }
public void check(int itemSize) throws Exception {
if (ptr + itemSize > end) {
overrun(itemSize, 1);
}
}
public int readU8() throws Exception {
check(1);
return b[ptr++] & 0xFF;
}
public int readU16() throws Exception {
check(2);
int v = ((b[ptr] & 0xFF) << 8) | (b[ptr + 1] & 0xFF);
ptr += 2;
return v;
}
public int readS32() throws Exception {
check(4);
int v = ((b[ptr] & 0xFF) << 24)
| ((b[ptr + 1] & 0xFF) << 16)
| ((b[ptr + 2] & 0xFF) << 8)
| (b[ptr + 3] & 0xFF);
ptr += 4;
return v;
}
public void readBytes(byte[] dst, int off, int len) throws Exception {
while (len > 0) {
if (ptr >= end) {
overrun(1, 1);
}
int n = Math.min(len, end - ptr);
System.arraycopy(b, ptr, dst, off, n);
ptr += n;
off += n;
len -= n;
}
}
protected abstract int overrun(int itemSize, int nItems) throws Exception;
}
@@ -0,0 +1,19 @@
package com.litoralregas.backend.vnc.rfb;
public class MemInStreamDesktop extends InStreamDesktop {
public MemInStreamDesktop(byte[] data, int offset, int len) {
b = data;
ptr = offset;
end = offset + len;
}
public int pos() {
return ptr;
}
@Override
protected int overrun(int itemSize, int nItems) throws Exception {
throw new Exception("MemInStream overrun: end of stream");
}
}
@@ -0,0 +1,568 @@
package com.litoralregas.backend.vnc.rfb;
import java.io.*;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class RfbProtoDesktop {
public static final String VERSION_MSG_3_3 = "RFB 003.003\n";
public static final String VERSION_MSG_3_7 = "RFB 003.007\n";
public static final String VERSION_MSG_3_8 = "RFB 003.008\n";
public static final int SEC_TYPE_INVALID = 0;
public static final int SEC_TYPE_NONE = 1;
public static final int SEC_TYPE_VNC_AUTH = 2;
public static final int SEC_TYPE_TIGHT = 16;
public static final int SEC_TYPE_ULTRA_34 = 0xfffffffa;
public static final int VNC_AUTH_OK = 0;
public static final int VNC_AUTH_FAILED = 1;
public static final int VNC_AUTH_TOO_MANY = 2;
public static final int FRAMEBUFFER_UPDATE = 0;
public static final int SET_COLOUR_MAP_ENTRIES = 1;
public static final int BELL = 2;
public static final int SET_PIXEL_FORMAT = 0;
public static final int SET_ENCODINGS = 2;
public static final int FRAMEBUFFER_UPDATE_REQUEST = 3;
public static final int POINTER_EVENT = 5;
public static final int ENCODING_RAW = 0;
public static final int ENCODING_COPY_RECT = 1;
public static final int ENCODING_RRE = 2;
public static final int ENCODING_CORRE = 4;
public static final int ENCODING_HEXTILE = 5;
public static final int ENCODING_ZLIB = 6;
public static final int ENCODING_ZRLE = 16;
public static final int ENCODING_TIGHT = 7;
public static final int ENCODING_X_CURSOR = 0xFFFFFF10;
public static final int ENCODING_RICH_CURSOR = 0xFFFFFF11;
public static final int ENCODING_POINTER_POS = 0xFFFFFF18;
public static final int ENCODING_LAST_RECT = 0xFFFFFF20;
public static final int ENCODING_NEW_FB_SIZE = 0xFFFFFF21;
public static final int HEXTILE_RAW = 1;
public static final int HEXTILE_BACKGROUND_SPECIFIED = 2;
public static final int HEXTILE_FOREGROUND_SPECIFIED = 4;
public static final int HEXTILE_ANY_SUBRECTS = 8;
public static final int HEXTILE_SUBRECTS_COLOURED = 16;
private static final int CONNECT_TIMEOUT_MS = 8000;
private static final int READ_TIMEOUT_MS = 30000;
private static final int IO_BUFFER_SIZE = 65536;
private final String host;
private final int port;
private final Socket sock;
private final DataInputStream is;
private final OutputStream os;
private boolean inNormalProtocol = false;
private boolean wereZlibUpdates = false;
private int serverMajor;
private int serverMinor;
private int clientMajor;
private int clientMinor;
private String desktopName;
private int framebufferWidth;
private int framebufferHeight;
private int bitsPerPixel;
private int depth;
private boolean bigEndian;
private boolean trueColour;
private int redMax;
private int greenMax;
private int blueMax;
private int redShift;
private int greenShift;
private int blueShift;
private int updateNRects;
private int updateRectX;
private int updateRectY;
private int updateRectW;
private int updateRectH;
private int updateRectEncoding;
private int copyRectSrcX;
private int copyRectSrcY;
private final byte[] framebufferUpdateRequest = new byte[10];
private byte[] setEncodingsBuf = new byte[64];
private final byte[] eventBuf = new byte[256];
private int eventBufLen;
public RfbProtoDesktop(String host, int port) throws IOException {
this.host = host;
this.port = port;
this.sock = new Socket();
// DO NOT set TCP_NODELAY — Nagle coalesces small writes (pointer
// events, framebuffer requests) into fewer packets, which massively
// reduces per-packet overhead on the udp2raw/WireGuard path.
this.sock.setKeepAlive(true);
this.sock.setReuseAddress(true);
this.sock.connect(new InetSocketAddress(host, port), CONNECT_TIMEOUT_MS);
this.sock.setSoTimeout(0);
// Increase receive buffer — large ZRLE/Tight frames arrive in bursts.
this.sock.setReceiveBufferSize(65536);
this.is = new DataInputStream(new BufferedInputStream(sock.getInputStream(), IO_BUFFER_SIZE));
this.os = new BufferedOutputStream(sock.getOutputStream(), IO_BUFFER_SIZE);
}
public DataInputStream getInputStream() {
return is;
}
public boolean isInNormalProtocol() {
return inNormalProtocol;
}
public boolean hasZlibUpdates() {
return wereZlibUpdates;
}
public int getServerMajor() { return serverMajor; }
public int getServerMinor() { return serverMinor; }
public int getClientMajor() { return clientMajor; }
public int getClientMinor() { return clientMinor; }
public String getDesktopName() { return desktopName; }
public int getFramebufferWidth() { return framebufferWidth; }
public int getFramebufferHeight() { return framebufferHeight; }
public int getBitsPerPixel() { return bitsPerPixel; }
public int getDepth() { return depth; }
public boolean isBigEndian() { return bigEndian; }
public boolean isTrueColour() { return trueColour; }
public int getRedMax() { return redMax; }
public int getGreenMax() { return greenMax; }
public int getBlueMax() { return blueMax; }
public int getRedShift() { return redShift; }
public int getGreenShift() { return greenShift; }
public int getBlueShift() { return blueShift; }
public int getUpdateNRects() { return updateNRects; }
public int getUpdateRectX() { return updateRectX; }
public int getUpdateRectY() { return updateRectY; }
public int getUpdateRectW() { return updateRectW; }
public int getUpdateRectH() { return updateRectH; }
public int getUpdateRectEncoding() { return updateRectEncoding; }
public int getCopyRectSrcX() { return copyRectSrcX; }
public int getCopyRectSrcY() { return copyRectSrcY; }
public synchronized void close() {
try {
sock.close();
} catch (Exception ignored) {
}
}
public void readVersionMsg() throws Exception {
byte[] b = new byte[12];
readFully(b);
if ((b[0] != 'R') || (b[1] != 'F') || (b[2] != 'B') || (b[3] != ' ')
|| (b[4] < '0') || (b[4] > '9') || (b[5] < '0') || (b[5] > '9')
|| (b[6] < '0') || (b[6] > '9') || (b[7] != '.')
|| (b[8] < '0') || (b[8] > '9') || (b[9] < '0') || (b[9] > '9')
|| (b[10] < '0') || (b[10] > '9') || (b[11] != '\n')) {
throw new Exception("Host " + host + " port " + port + " is not an RFB server");
}
serverMajor = (b[4] - '0') * 100 + (b[5] - '0') * 10 + (b[6] - '0');
serverMinor = (b[8] - '0') * 100 + (b[9] - '0') * 10 + (b[10] - '0');
if (serverMajor < 3) {
throw new Exception("RFB server does not support protocol version 3");
}
}
public synchronized void writeVersionMsg() throws IOException {
clientMajor = 3;
if (serverMajor > 3 || serverMinor >= 8) {
clientMinor = 8;
os.write(VERSION_MSG_3_8.getBytes(StandardCharsets.US_ASCII));
} else if (serverMinor >= 7) {
clientMinor = 7;
os.write(VERSION_MSG_3_7.getBytes(StandardCharsets.US_ASCII));
} else {
clientMinor = 3;
os.write(VERSION_MSG_3_3.getBytes(StandardCharsets.US_ASCII));
}
os.flush();
}
public int negotiateSecurity() throws Exception {
return (clientMinor >= 7) ? selectSecurityType() : readSecurityType();
}
public int readSecurityType() throws Exception {
int secType = is.readInt();
switch (secType) {
case SEC_TYPE_INVALID:
readConnFailedReason();
return SEC_TYPE_INVALID;
case SEC_TYPE_NONE:
case SEC_TYPE_VNC_AUTH:
return secType;
default:
throw new Exception("Unknown security type from RFB server: " + secType);
}
}
int selectSecurityType() throws Exception {
int nSecTypes = is.readUnsignedByte();
if (nSecTypes == 0) {
readConnFailedReason();
return SEC_TYPE_INVALID;
}
byte[] secTypes = new byte[nSecTypes];
readFully(secTypes);
int selected = SEC_TYPE_INVALID;
// Prefer VNC auth over no-auth if both are offered.
for (byte secType : secTypes) {
if ((secType & 0xFF) == SEC_TYPE_VNC_AUTH) {
selected = SEC_TYPE_VNC_AUTH;
break;
}
}
if (selected == SEC_TYPE_INVALID) {
for (byte secType : secTypes) {
if ((secType & 0xFF) == SEC_TYPE_NONE) {
selected = SEC_TYPE_NONE;
break;
}
}
}
if (selected == SEC_TYPE_INVALID) {
throw new Exception("Server did not offer supported security type");
}
os.write(selected);
os.flush();
return selected;
}
public void authenticateVNC(String pw) throws Exception {
byte[] challenge = new byte[16];
readFully(challenge);
if (pw == null) {
pw = "";
}
if (pw.length() > 8) pw = pw.substring(0, 8);
int firstZero = pw.indexOf(0);
if (firstZero != -1) pw = pw.substring(0, firstZero);
byte[] key = new byte[8];
byte[] pwBytes = pw.getBytes(StandardCharsets.ISO_8859_1);
System.arraycopy(pwBytes, 0, key, 0, Math.min(pwBytes.length, key.length));
DesCipherDesktop des = new DesCipherDesktop(key);
des.encrypt(challenge, 0, challenge, 0);
des.encrypt(challenge, 8, challenge, 8);
os.write(challenge);
os.flush();
readSecurityResult("VNC authentication");
}
public void readSecurityResult(String authType) throws Exception {
int securityResult = is.readInt();
switch (securityResult) {
case VNC_AUTH_OK:
return;
case VNC_AUTH_FAILED:
if (clientMinor >= 8) readConnFailedReason();
throw new Exception(authType + ": failed");
case VNC_AUTH_TOO_MANY:
throw new Exception(authType + ": failed, too many tries");
default:
throw new Exception(authType + ": unknown result " + securityResult);
}
}
void readConnFailedReason() throws Exception {
int reasonLen = is.readInt();
if (reasonLen < 0 || reasonLen > 1024 * 1024) {
throw new IOException("Invalid RFB failure reason length: " + reasonLen);
}
byte[] reason = new byte[reasonLen];
readFully(reason);
throw new Exception(new String(reason, StandardCharsets.UTF_8));
}
public synchronized void writeClientInit() throws IOException {
os.write(1);
os.flush();
}
public void readServerInit() throws IOException {
framebufferWidth = is.readUnsignedShort();
framebufferHeight = is.readUnsignedShort();
bitsPerPixel = is.readUnsignedByte();
depth = is.readUnsignedByte();
bigEndian = (is.readUnsignedByte() != 0);
trueColour = (is.readUnsignedByte() != 0);
redMax = is.readUnsignedShort();
greenMax = is.readUnsignedShort();
blueMax = is.readUnsignedShort();
redShift = is.readUnsignedByte();
greenShift = is.readUnsignedByte();
blueShift = is.readUnsignedByte();
skipFully(3);
int nameLength = is.readInt();
if (nameLength < 0 || nameLength > 16 * 1024 * 1024) {
throw new IOException("Invalid desktop name length: " + nameLength);
}
byte[] name = new byte[nameLength];
readFully(name);
desktopName = new String(name, StandardCharsets.UTF_8);
inNormalProtocol = true;
}
public void setFramebufferSize(int width, int height) {
framebufferWidth = Math.max(0, width);
framebufferHeight = Math.max(0, height);
}
public int readServerMessageType() throws IOException {
return is.readUnsignedByte();
}
public void readFramebufferUpdate() throws IOException {
is.readUnsignedByte(); // padding
updateNRects = is.readUnsignedShort();
}
public void readFramebufferUpdateRectHdr() throws Exception {
updateRectX = is.readUnsignedShort();
updateRectY = is.readUnsignedShort();
updateRectW = is.readUnsignedShort();
updateRectH = is.readUnsignedShort();
updateRectEncoding = is.readInt();
if (updateRectEncoding == ENCODING_ZLIB || updateRectEncoding == ENCODING_ZRLE) {
wereZlibUpdates = true;
}
if (updateRectEncoding == ENCODING_LAST_RECT) {
updateNRects = 0;
return;
}
if (updateRectEncoding == ENCODING_NEW_FB_SIZE) {
setFramebufferSize(updateRectW, updateRectH);
return;
}
if (updateRectEncoding == ENCODING_POINTER_POS) {
return;
}
// Pseudo-encodings are negative. Their payload handling is done by the caller.
if (updateRectEncoding < 0) {
return;
}
updateRectX = Math.max(0, Math.min(updateRectX, framebufferWidth));
updateRectY = Math.max(0, Math.min(updateRectY, framebufferHeight));
updateRectW = Math.max(0, Math.min(updateRectW, framebufferWidth - updateRectX));
updateRectH = Math.max(0, Math.min(updateRectH, framebufferHeight - updateRectY));
if (updateRectX + updateRectW > framebufferWidth || updateRectY + updateRectH > framebufferHeight) {
throw new Exception("Framebuffer update rectangle too large: " + updateRectW + "x" + updateRectH
+ " at (" + updateRectX + "," + updateRectY + ")");
}
}
public void readCopyRect() throws IOException {
copyRectSrcX = is.readUnsignedShort();
copyRectSrcY = is.readUnsignedShort();
}
public synchronized void writeFramebufferUpdateRequest(int x, int y, int w, int h, boolean incremental) throws IOException {
framebufferUpdateRequest[0] = (byte) FRAMEBUFFER_UPDATE_REQUEST;
framebufferUpdateRequest[1] = (byte) (incremental ? 1 : 0);
framebufferUpdateRequest[2] = (byte) ((x >> 8) & 0xff);
framebufferUpdateRequest[3] = (byte) (x & 0xff);
framebufferUpdateRequest[4] = (byte) ((y >> 8) & 0xff);
framebufferUpdateRequest[5] = (byte) (y & 0xff);
framebufferUpdateRequest[6] = (byte) ((w >> 8) & 0xff);
framebufferUpdateRequest[7] = (byte) (w & 0xff);
framebufferUpdateRequest[8] = (byte) ((h >> 8) & 0xff);
framebufferUpdateRequest[9] = (byte) (h & 0xff);
os.write(framebufferUpdateRequest);
os.flush();
}
public synchronized void writeSetPixelFormat(
int bitsPerPixel, int depth, boolean bigEndian, boolean trueColour,
int redMax, int greenMax, int blueMax,
int redShift, int greenShift, int blueShift, boolean greyScale
) throws IOException {
byte[] b = new byte[20];
b[0] = (byte) SET_PIXEL_FORMAT;
b[1] = 0;
b[2] = 0;
b[3] = 0;
b[4] = (byte) bitsPerPixel;
b[5] = (byte) depth;
b[6] = (byte) (bigEndian ? 1 : 0);
b[7] = (byte) (trueColour ? 1 : 0);
b[8] = (byte) ((redMax >> 8) & 0xff);
b[9] = (byte) (redMax & 0xff);
b[10] = (byte) ((greenMax >> 8) & 0xff);
b[11] = (byte) (greenMax & 0xff);
b[12] = (byte) ((blueMax >> 8) & 0xff);
b[13] = (byte) (blueMax & 0xff);
b[14] = (byte) redShift;
b[15] = (byte) greenShift;
b[16] = (byte) blueShift;
b[17] = (byte) (greyScale ? 1 : 0);
b[18] = 0;
b[19] = 0;
os.write(b);
os.flush();
}
public synchronized void writeSetEncodings(int[] encs, int len) throws IOException {
int required = 4 + 4 * len;
if (setEncodingsBuf.length < required) {
setEncodingsBuf = new byte[Math.max(required, setEncodingsBuf.length * 2)];
}
setEncodingsBuf[0] = (byte) SET_ENCODINGS;
setEncodingsBuf[1] = 0;
setEncodingsBuf[2] = (byte) ((len >> 8) & 0xff);
setEncodingsBuf[3] = (byte) (len & 0xff);
for (int i = 0; i < len; i++) {
int enc = encs[i];
int p = 4 + 4 * i;
setEncodingsBuf[p] = (byte) ((enc >> 24) & 0xff);
setEncodingsBuf[p + 1] = (byte) ((enc >> 16) & 0xff);
setEncodingsBuf[p + 2] = (byte) ((enc >> 8) & 0xff);
setEncodingsBuf[p + 3] = (byte) (enc & 0xff);
}
os.write(setEncodingsBuf, 0, required);
os.flush();
}
public synchronized void writePointerEvent(int x, int y, int modifiers, int pointerMask) throws IOException {
eventBufLen = 0;
appendPointerEvent(x, y, pointerMask);
os.write(eventBuf, 0, eventBufLen);
os.flush();
}
/**
* Lower-latency click path: sends up/down/up plus one incremental framebuffer request
* in a single write/flush instead of four separate writes/flushes.
*/
public synchronized void writeClickAndFramebufferRequest(
int x,
int y,
int framebufferWidth,
int framebufferHeight
) throws IOException {
eventBufLen = 0;
appendPointerEvent(x, y, 0);
appendPointerEvent(x, y, 1);
appendPointerEvent(x, y, 0);
appendFramebufferUpdateRequest(0, 0, framebufferWidth, framebufferHeight, true);
os.write(eventBuf, 0, eventBufLen);
os.flush();
}
/**
* Useful for drag/move support: caller can coalesce many pointer moves and flush once.
*/
public synchronized void writePointerEventNoFlush(int x, int y, int pointerMask) throws IOException {
eventBufLen = 0;
appendPointerEvent(x, y, pointerMask);
os.write(eventBuf, 0, eventBufLen);
}
public synchronized void flush() throws IOException {
os.flush();
}
private void appendPointerEvent(int x, int y, int pointerMask) {
ensureEventCapacity(eventBufLen + 6);
eventBuf[eventBufLen++] = (byte) POINTER_EVENT;
eventBuf[eventBufLen++] = (byte) pointerMask;
eventBuf[eventBufLen++] = (byte) ((x >> 8) & 0xff);
eventBuf[eventBufLen++] = (byte) (x & 0xff);
eventBuf[eventBufLen++] = (byte) ((y >> 8) & 0xff);
eventBuf[eventBufLen++] = (byte) (y & 0xff);
}
private void appendFramebufferUpdateRequest(int x, int y, int w, int h, boolean incremental) {
ensureEventCapacity(eventBufLen + 10);
eventBuf[eventBufLen++] = (byte) FRAMEBUFFER_UPDATE_REQUEST;
eventBuf[eventBufLen++] = (byte) (incremental ? 1 : 0);
eventBuf[eventBufLen++] = (byte) ((x >> 8) & 0xff);
eventBuf[eventBufLen++] = (byte) (x & 0xff);
eventBuf[eventBufLen++] = (byte) ((y >> 8) & 0xff);
eventBuf[eventBufLen++] = (byte) (y & 0xff);
eventBuf[eventBufLen++] = (byte) ((w >> 8) & 0xff);
eventBuf[eventBufLen++] = (byte) (w & 0xff);
eventBuf[eventBufLen++] = (byte) ((h >> 8) & 0xff);
eventBuf[eventBufLen++] = (byte) (h & 0xff);
}
private void ensureEventCapacity(int needed) {
if (needed > eventBuf.length) {
throw new IllegalStateException("Internal event buffer too small: " + needed);
}
}
public void readFully(byte[] b) throws IOException {
readFully(b, 0, b.length);
}
public void readFully(byte[] b, int off, int len) throws IOException {
is.readFully(b, off, len);
}
public void skipFully(int len) throws IOException {
int remaining = len;
while (remaining > 0) {
int skipped = is.skipBytes(remaining);
if (skipped <= 0) {
if (is.read() == -1) {
throw new EOFException("Unexpected EOF while skipping " + len + " bytes");
}
skipped = 1;
}
remaining -= skipped;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
package com.litoralregas.backend.vnc.rfb;
import java.util.zip.DataFormatException;
import java.util.zip.Inflater;
public class ZlibInStreamDesktop extends InStreamDesktop {
static final int DEFAULT_BUF_SIZE = 16384;
private InStreamDesktop underlying;
private int bufSize;
private int ptrOffset;
private Inflater inflater;
private int bytesIn;
public ZlibInStreamDesktop(int bufSize) {
this.bufSize = bufSize;
this.b = new byte[bufSize];
this.ptr = this.end = this.ptrOffset = 0;
this.inflater = new Inflater();
}
public ZlibInStreamDesktop() {
this(DEFAULT_BUF_SIZE);
}
public void setUnderlying(InStreamDesktop is, int bytesIn) {
this.underlying = is;
this.bytesIn = bytesIn;
this.ptr = this.end = 0;
}
public void reset() throws Exception {
ptr = end = 0;
if (underlying == null) return;
while (bytesIn > 0) {
decompress();
end = 0;
}
underlying = null;
}
public int pos() {
return ptrOffset + ptr;
}
@Override
protected int overrun(int itemSize, int nItems) throws Exception {
if (itemSize > bufSize) {
throw new Exception("ZlibInStream overrun: max itemSize exceeded");
}
if (underlying == null) {
throw new Exception("ZlibInStream overrun: no underlying stream");
}
if (end - ptr != 0) {
System.arraycopy(b, ptr, b, 0, end - ptr);
}
ptrOffset += ptr;
end -= ptr;
ptr = 0;
while (end < itemSize) {
decompress();
}
if (itemSize * nItems > end) {
nItems = end / itemSize;
}
return nItems;
}
private void decompress() throws Exception {
try {
underlying.check(1);
int availIn = underlying.getend() - underlying.getptr();
if (availIn > bytesIn) availIn = bytesIn;
if (inflater.needsInput()) {
inflater.setInput(underlying.getbuf(), underlying.getptr(), availIn);
}
int n = inflater.inflate(b, end, bufSize - end);
end += n;
if (inflater.needsInput()) {
bytesIn -= availIn;
underlying.setptr(underlying.getptr() + availIn);
}
} catch (DataFormatException e) {
throw new Exception("ZlibInStream inflate failed", e);
}
}
}
@@ -0,0 +1,221 @@
package com.litoralregas.backend.vnc.websocket;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.litoralregas.backend.vnc.rfb.VncClientDesktop;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.BinaryMessage;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.BinaryWebSocketHandler;
import java.nio.ByteBuffer;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class VncStreamHandler extends BinaryWebSocketHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
private final Map<String, VncClientDesktop> clients = new ConcurrentHashMap<>();
@Override
protected void handleTextMessage(
WebSocketSession session,
TextMessage message
) {
try {
JsonNode json = objectMapper.readTree(message.getPayload());
String type = json.path("type").asText();
if ("connect".equals(type)) {
handleConnect(session, json);
return;
}
if ("click".equals(type)) {
handleClick(session, json);
return;
}
if ("disconnect".equals(type)) {
handleDisconnect(session);
return;
}
sendErrorSafe(session, "Unknown VNC message type: " + type);
} catch (Exception error) {
sendErrorSafe(session, error.getMessage());
}
}
private void handleConnect(
WebSocketSession session,
JsonNode json
) {
closeClient(session);
String host = json.path("host").asText();
int port = json.path("port").asInt(5900);
String password = json.path("password").asText();
if (host == null || host.isBlank()) {
sendErrorSafe(session, "Missing VNC host");
return;
}
sendStateSafe(session, "CONNECTING");
VncClientDesktop client = new VncClientDesktop();
client.setListener(createFrameListener(session));
clients.put(session.getId(), client);
try {
client.connect(host, port, password);
} catch (Exception error) {
clients.remove(session.getId());
sendErrorSafe(session, error.getMessage());
}
}
private void handleClick(
WebSocketSession session,
JsonNode json
) {
VncClientDesktop client = clients.get(session.getId());
if (client == null) {
sendErrorSafe(session, "No active VNC client for this websocket session");
return;
}
float x = (float) json.path("x").asDouble();
float y = (float) json.path("y").asDouble();
client.sendClick(x, y);
}
private void handleDisconnect(WebSocketSession session) {
closeClient(session);
sendStateSafe(session, "DISCONNECTED");
}
private VncClientDesktop.VncFrameListener createFrameListener(WebSocketSession session) {
return new VncClientDesktop.VncFrameListener() {
@Override
public void onConnected() {
sendStateSafe(session, "CONNECTED");
}
@Override
public void onFirstFrameReceived() {
sendStateSafe(session, "FIRST_FRAME");
}
@Override
public void onFrameUpdated(byte[] pixelBuffer, int width, int height) {
sendFrameSafe(session, pixelBuffer, width, height);
}
@Override
public void onError(String message) {
sendErrorSafe(session, message);
}
@Override
public void onDisconnected() {
sendStateSafe(session, "DISCONNECTED");
}
};
}
private void sendFrameSafe(
WebSocketSession session,
byte[] pixels,
int width,
int height
) {
try {
if (!session.isOpen()) {
closeClient(session);
return;
}
byte[] header = ByteBuffer.allocate(8)
.putInt(width)
.putInt(height)
.array();
ByteBuffer buffer = ByteBuffer.allocate(header.length + pixels.length);
buffer.put(header);
buffer.put(pixels);
buffer.flip();
synchronized (session) {
session.sendMessage(new BinaryMessage(buffer));
}
} catch (Exception error) {
closeClient(session);
}
}
private void sendStateSafe(
WebSocketSession session,
String state
) {
try {
if (!session.isOpen()) {
return;
}
synchronized (session) {
session.sendMessage(new TextMessage(
objectMapper.writeValueAsString(Map.of(
"type", "state",
"state", state
))
));
}
} catch (Exception ignored) {
}
}
private void sendErrorSafe(
WebSocketSession session,
String message
) {
try {
if (!session.isOpen()) {
return;
}
synchronized (session) {
session.sendMessage(new TextMessage(
objectMapper.writeValueAsString(Map.of(
"type", "error",
"message", message == null ? "Unknown VNC error" : message
))
));
}
} catch (Exception ignored) {
}
}
private void closeClient(WebSocketSession session) {
VncClientDesktop client = clients.remove(session.getId());
if (client != null) {
client.disconnect();
}
}
@Override
public void afterConnectionClosed(
WebSocketSession session,
CloseStatus status
) {
closeClient(session);
}
}
@@ -0,0 +1,23 @@
package com.litoralregas.backend.vnc.websocket;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class VncWebSocketConfig implements WebSocketConfigurer {
private final VncStreamHandler vncStreamHandler;
public VncWebSocketConfig(VncStreamHandler vncStreamHandler) {
this.vncStreamHandler = vncStreamHandler;
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(vncStreamHandler, "/ws/vnc")
.setAllowedOrigins("*");
}
}
@@ -0,0 +1,35 @@
package com.litoralregas.backend.weather;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "weather")
public class WeatherApiProperties {
private String apiKey;
private String baseUrl;
private int cacheMinutes = 30;
public String getApiKey() {
return apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
public String getBaseUrl() {
return baseUrl;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
public int getCacheMinutes() {
return cacheMinutes;
}
public void setCacheMinutes(int cacheMinutes) {
this.cacheMinutes = cacheMinutes;
}
}
@@ -0,0 +1,47 @@
package com.litoralregas.backend.weather;
import java.util.Map;
public final class WeatherConditionMapper {
private static final Map<String, String> CONDITIONS = Map.ofEntries(
Map.entry("Sunny", "Ensolarado"),
Map.entry("Clear", "Céu limpo"),
Map.entry("Partly Cloudy", "Parcialmente nublado"),
Map.entry("Cloudy", "Nublado"),
Map.entry("Overcast", "Encoberto"),
Map.entry("Mist", "Nevoeiro"),
Map.entry("Fog", "Nevoeiro"),
Map.entry("Freezing fog", "Nevoeiro gelado"),
Map.entry("Patchy rain nearby", "Possibilidade de chuva"),
Map.entry("Light rain", "Chuva fraca"),
Map.entry("Moderate rain", "Chuva moderada"),
Map.entry("Heavy rain", "Chuva forte"),
Map.entry("Patchy light rain", "Aguaceiros fracos"),
Map.entry("Moderate or heavy rain shower", "Aguaceiros fortes"),
Map.entry("Thundery outbreaks nearby", "Trovoada próxima"),
Map.entry("Patchy light rain with thunder", "Chuva fraca com trovoada"),
Map.entry("Light drizzle", "Chuvisco fraco"),
Map.entry("Moderate drizzle", "Chuvisco moderado"),
Map.entry("Patchy snow nearby", "Possibilidade de neve"),
Map.entry("Light snow", "Neve fraca"),
Map.entry("Moderate snow", "Neve moderada"),
Map.entry("Heavy snow", "Neve forte")
);
private WeatherConditionMapper() {
}
public static String normalize(String condition) {
if (condition == null || condition.isBlank()) {
return null;
}
return CONDITIONS.getOrDefault(condition, condition);
}
}
@@ -0,0 +1,46 @@
package com.litoralregas.backend.weather;
import com.fasterxml.jackson.databind.JsonNode;
import com.litoralregas.backend.weather.dto.WeatherConfiguredLocationResponse;
import com.litoralregas.backend.weather.dto.WeatherForecastResponse;
import com.litoralregas.backend.weather.dto.WeatherLocationUpdateRequest;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/weather")
public class WeatherController {
private final WeatherService weatherService;
public WeatherController(WeatherService weatherService) {
this.weatherService = weatherService;
}
@GetMapping("/forecast")
public WeatherForecastResponse getForecast(
@RequestParam(defaultValue = "7") int days
) {
return weatherService.getConfiguredForecast(days);
}
@GetMapping("/location")
public WeatherConfiguredLocationResponse getLocation() {
return weatherService.getConfiguredLocation();
}
@PutMapping("/location")
public WeatherConfiguredLocationResponse updateLocation(
@RequestBody WeatherLocationUpdateRequest request
) {
return weatherService.updateConfiguredLocation(
request.latitude(),
request.longitude(),
request.locationName()
);
}
@GetMapping("/search")
public JsonNode search(@RequestParam String query) {
return weatherService.search(query);
}
}
@@ -0,0 +1,306 @@
package com.litoralregas.backend.weather;
import com.fasterxml.jackson.databind.JsonNode;
import com.litoralregas.backend.weather.dto.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.UriComponentsBuilder;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class WeatherService {
private final WeatherApiProperties properties;
private final RestClient restClient;
private final Map<String, CachedWeatherResponse> cache = new ConcurrentHashMap<>();
private final WeatherSettingsRepository weatherSettingsRepository;
private volatile Double overrideLatitude;
private volatile Double overrideLongitude;
private volatile String overrideLocationName;
public WeatherService(
WeatherApiProperties properties,
WeatherSettingsRepository weatherSettingsRepository
) {
this.properties = properties;
this.weatherSettingsRepository = weatherSettingsRepository;
this.restClient = RestClient.builder()
.baseUrl(properties.getBaseUrl())
.build();
}
public WeatherForecastResponse getForecast(double latitude, double longitude, int days) {
int safeDays = Math.max(1, Math.min(days, 7));
String q = roundCoordinate(latitude) + "," + roundCoordinate(longitude);
String cacheKey = "forecast:" + q + ":" + safeDays;
JsonNode payload = getCached(cacheKey, () ->
restClient.get()
.uri(uriBuilder -> uriBuilder
.path("/forecast.json")
.queryParam("key", properties.getApiKey())
.queryParam("q", q)
.queryParam("days", safeDays)
.queryParam("aqi", "yes")
.queryParam("alerts", "yes")
.build()
)
.retrieve()
.body(JsonNode.class)
);
return toForecastResponse(payload);
}
public JsonNode search(String query) {
String cleanQuery = query == null ? "" : query.trim();
if (cleanQuery.length() < 2) {
throw new IllegalArgumentException("Search query must have at least 2 characters.");
}
String cacheKey = "search:" + cleanQuery.toLowerCase();
return getCached(cacheKey, () ->
restClient.get()
.uri(uriBuilder -> uriBuilder
.path("/search.json")
.queryParam("key", properties.getApiKey())
.queryParam("q", cleanQuery)
.build()
)
.retrieve()
.body(JsonNode.class)
);
}
private JsonNode getCached(String cacheKey, WeatherSupplier supplier) {
CachedWeatherResponse cached = cache.get(cacheKey);
if (cached != null && !cached.isExpired(properties.getCacheMinutes())) {
return cached.payload();
}
JsonNode payload = supplier.get();
cache.put(cacheKey, new CachedWeatherResponse(payload, Instant.now()));
return payload;
}
@FunctionalInterface
private interface WeatherSupplier {
JsonNode get();
}
private record CachedWeatherResponse(JsonNode payload, Instant storedAt) {
boolean isExpired(int cacheMinutes) {
return storedAt.plus(Duration.ofMinutes(cacheMinutes)).isBefore(Instant.now());
}
}
private WeatherForecastResponse toForecastResponse(JsonNode payload) {
JsonNode location = payload.path("location");
JsonNode forecastDays = payload.path("forecast").path("forecastday");
List<WeatherDailyDto> daily = new ArrayList<>();
for (JsonNode dayNode : forecastDays) {
JsonNode day = dayNode.path("day");
JsonNode astro = dayNode.path("astro");
JsonNode hours = dayNode.path("hour");
WindSummary windSummary = summarizeWind(hours);
daily.add(new WeatherDailyDto(
textOrNull(dayNode, "date"),
doubleOrNull(day, "maxtemp_c"),
doubleOrNull(day, "mintemp_c"),
doubleOrNull(day, "avgtemp_c"),
doubleOrNull(day, "totalprecip_mm"),
intOrNull(day, "daily_chance_of_rain"),
doubleOrNull(day, "maxwind_kph"),
windSummary.averageWindKph(),
windSummary.averageWindDegree(),
windSummary.averageWindDirection(),
doubleOrNull(day, "avghumidity"),
doubleOrNull(day, "avgvis_km"),
doubleOrNull(day, "uv"),
textOrNull(astro, "sunrise"),
textOrNull(astro, "sunset"),
toCondition(day.path("condition"))
));
}
return new WeatherForecastResponse(
new WeatherLocationDto(
textOrNull(location, "name"),
textOrNull(location, "region"),
textOrNull(location, "country"),
doubleOrNull(location, "lat"),
doubleOrNull(location, "lon"),
textOrNull(location, "localtime")
),
daily
);
}
private WeatherConditionDto toCondition(JsonNode condition) {
if (condition == null || condition.isMissingNode() || condition.isNull()) {
return null;
}
return new WeatherConditionDto(
WeatherConditionMapper.normalize(
textOrNull(condition, "text")
),
normalizeIconUrl(textOrNull(condition, "icon")),
intOrNull(condition, "code")
);
}
private String normalizeIconUrl(String icon) {
if (icon == null || icon.isBlank()) return null;
if (icon.startsWith("//")) {
return "https:" + icon;
}
return icon;
}
private String textOrNull(JsonNode node, String field) {
JsonNode value = node.path(field);
return value.isMissingNode() || value.isNull() ? null : value.asText();
}
private Double doubleOrNull(JsonNode node, String field) {
JsonNode value = node.path(field);
return value.isMissingNode() || value.isNull() ? null : value.asDouble();
}
private Integer intOrNull(JsonNode node, String field) {
JsonNode value = node.path(field);
return value.isMissingNode() || value.isNull() ? null : value.asInt();
}
private String roundCoordinate(double value) {
return String.format(Locale.US, "%.3f", value);
}
public WeatherForecastResponse getConfiguredForecast(int days) {
WeatherSettings settings = getSettings();
if (!settings.isEnabled()) {
throw new IllegalStateException("Weather forecast is disabled.");
}
return getForecast(
settings.getLatitude(),
settings.getLongitude(),
days
);
}
public WeatherConfiguredLocationResponse getConfiguredLocation() {
WeatherSettings settings = getSettings();
return new WeatherConfiguredLocationResponse(
settings.isEnabled(),
settings.getLatitude(),
settings.getLongitude(),
settings.getLocationName()
);
}
public WeatherConfiguredLocationResponse updateConfiguredLocation(
double latitude,
double longitude,
String locationName
) {
WeatherSettings settings = getSettings();
settings.setLatitude(latitude);
settings.setLongitude(longitude);
settings.setLocationName(locationName);
settings.setUpdatedAt(Instant.now());
weatherSettingsRepository.save(settings);
cache.clear();
return getConfiguredLocation();
}
private WeatherSettings getSettings() {
return weatherSettingsRepository.findById(1)
.orElseThrow(() ->
new IllegalStateException("Weather settings not configured."));
}
private record WindSummary(
Double averageWindKph,
Double averageWindDegree,
String averageWindDirection
) {}
private WindSummary summarizeWind(JsonNode hours) {
double windSum = 0;
double sinSum = 0;
double cosSum = 0;
int windCount = 0;
int degreeCount = 0;
for (JsonNode hour : hours) {
Double windKph = doubleOrNull(hour, "wind_kph");
Double windDegree = doubleOrNull(hour, "wind_degree");
if (windKph != null) {
windSum += windKph;
windCount++;
}
if (windDegree != null) {
double radians = Math.toRadians(windDegree);
sinSum += Math.sin(radians);
cosSum += Math.cos(radians);
degreeCount++;
}
}
Double averageWindKph = windCount > 0 ? windSum / windCount : null;
if (degreeCount == 0) {
return new WindSummary(averageWindKph, null, null);
}
double averageRadians = Math.atan2(
sinSum / degreeCount,
cosSum / degreeCount
);
double averageDegree = (Math.toDegrees(averageRadians) + 360) % 360;
return new WindSummary(
averageWindKph,
averageDegree,
directionName(averageDegree)
);
}
private String directionName(double degree) {
String[] labels = {"N", "NE", "E", "SE", "S", "SW", "W", "NW"};
int index = (int) Math.round(degree / 45.0) % 8;
return labels[index];
}
}
@@ -0,0 +1,76 @@
package com.litoralregas.backend.weather;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "weather_settings")
public class WeatherSettings {
@Id
private Integer id;
@Column(nullable = false)
private boolean enabled;
@Column(nullable = false)
private double latitude;
@Column(nullable = false)
private double longitude;
@Column(name = "location_name", nullable = false)
private String locationName;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public double getLatitude() {
return latitude;
}
public void setLatitude(double latitude) {
this.latitude = latitude;
}
public double getLongitude() {
return longitude;
}
public void setLongitude(double longitude) {
this.longitude = longitude;
}
public String getLocationName() {
return locationName;
}
public void setLocationName(String locationName) {
this.locationName = locationName;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
}
@@ -0,0 +1,7 @@
package com.litoralregas.backend.weather;
import org.springframework.data.jpa.repository.JpaRepository;
public interface WeatherSettingsRepository
extends JpaRepository<WeatherSettings, Integer> {
}
@@ -0,0 +1,7 @@
package com.litoralregas.backend.weather.dto;
public record WeatherConditionDto(
String text,
String icon,
Integer code
) {}
@@ -0,0 +1,8 @@
package com.litoralregas.backend.weather.dto;
public record WeatherConfiguredLocationResponse(
boolean enabled,
double latitude,
double longitude,
String locationName
) {}
@@ -0,0 +1,24 @@
package com.litoralregas.backend.weather.dto;
public record WeatherDailyDto(
String date,
Double maxTemperatureC,
Double minTemperatureC,
Double averageTemperatureC,
Double totalPrecipitationMm,
Integer dailyRainChance,
Double maxWindKph,
Double averageWindKph,
Double averageWindDegree,
String averageWindDirection,
Double averageHumidity,
Double averageVisibilityKm,
Double uv,
String sunrise,
String sunset,
WeatherConditionDto condition
) {}
@@ -0,0 +1,8 @@
package com.litoralregas.backend.weather.dto;
import java.util.List;
public record WeatherForecastResponse(
WeatherLocationDto location,
List<WeatherDailyDto> daily
) {}
@@ -0,0 +1,10 @@
package com.litoralregas.backend.weather.dto;
public record WeatherLocationDto(
String name,
String region,
String country,
Double latitude,
Double longitude,
String localTime
) {}
@@ -0,0 +1,7 @@
package com.litoralregas.backend.weather.dto;
public record WeatherLocationUpdateRequest(
double latitude,
double longitude,
String locationName
) {}
@@ -0,0 +1,25 @@
package com.litoralregas.backend.websocket.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic");
registry.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*");
}
}
@@ -0,0 +1,23 @@
package com.litoralregas.backend.websocket.dashboard;
import com.litoralregas.backend.dashboard.DashboardOverviewResponse;
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;
public DashboardOverviewWebSocketPublisher(
SimpMessagingTemplate messagingTemplate
) {
this.messagingTemplate = messagingTemplate;
}
public void publishOverview(DashboardOverviewResponse overview) {
messagingTemplate.convertAndSend(DESTINATION, overview);
}
}
@@ -0,0 +1,13 @@
package com.litoralregas.backend.websocket.telemetry;
import com.litoralregas.backend.acquisition.telemetry.TelemetrySnapshot;
import java.time.Instant;
import java.util.Collection;
public record TelemetryBroadcastMessage(
Instant timestamp,
Integer sensorCount,
Collection<TelemetrySnapshot> snapshots
) {
}
@@ -0,0 +1,38 @@
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 {
private static final String DESTINATION = "/topic/telemetry/latest";
private final SimpMessagingTemplate messagingTemplate;
private final TelemetryCache telemetryCache;
public TelemetryWebSocketPublisher(
SimpMessagingTemplate messagingTemplate,
TelemetryCache telemetryCache
) {
this.messagingTemplate = messagingTemplate;
this.telemetryCache = telemetryCache;
}
public void publishLatestTelemetry() {
Collection<TelemetrySnapshot> snapshots = telemetryCache.getAll();
TelemetryBroadcastMessage message = new TelemetryBroadcastMessage(
Instant.now(),
snapshots.size(),
snapshots
);
messagingTemplate.convertAndSend(DESTINATION, message);
}
}
+29 -1
View File
@@ -1,3 +1,6 @@
server:
port: 18450
spring:
application:
name: backend
@@ -16,13 +19,38 @@ spring:
locations: classpath:db/migration
litoralregas:
runtime:
mode: Local
controller-name: Estufa_Litoral
modbus:
host: 198.19.0.176
port: 533
timeout-millis: 500
max-attempts: 3
retry-delay-millis: 1000
acquisition:
scheduler:
enabled: true
fixed-delay-millis: 3000
fixed-delay-millis: 3000 # change here for longer wait between cycles
weather:
enabled: true
latitude: 40.4289
longitude: -8.7375
location-name: Mira
modules:
climate:
enabled: false
exterior-enabled: true
enabled-sites:
- 1
irrigation:
enabled: true
weather:
api-key: 0aa355536b6c469eb4b82226262505
base-url: https://api.weatherapi.com/v1
cache-minutes: 720
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,14 +1,35 @@
CREATE TABLE sensor_definition (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key VARCHAR(255) NOT NULL UNIQUE,
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,
modbus_address INTEGER,
bit_offset INTEGER,
value_type VARCHAR(50) NOT NULL,
unit VARCHAR(50),
decimal_places INTEGER NOT NULL DEFAULT 0,
scale_factor REAL NOT NULL DEFAULT 1.0,
signed BOOLEAN NOT NULL DEFAULT FALSE,
valid_min REAL,
valid_max REAL,
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
);
@@ -0,0 +1,23 @@
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);
@@ -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);
@@ -0,0 +1,5 @@
CREATE INDEX IF NOT EXISTS idx_historian_key_time
ON historian_sample(key_name, sampled_at);
CREATE INDEX IF NOT EXISTS idx_historian_sampled_at
ON historian_sample(sampled_at);
@@ -0,0 +1,25 @@
CREATE TABLE weather_settings (
id INTEGER PRIMARY KEY,
enabled BOOLEAN NOT NULL,
latitude DOUBLE NOT NULL,
longitude DOUBLE NOT NULL,
location_name VARCHAR(255) NOT NULL,
updated_at TIMESTAMP NOT NULL
);
INSERT INTO weather_settings (
id,
enabled,
latitude,
longitude,
location_name,
updated_at
)
VALUES (
1,
TRUE,
40.4289,
-8.7375,
'Mira',
CURRENT_TIMESTAMP
);
@@ -0,0 +1,12 @@
CREATE TABLE chart_workspace (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope VARCHAR(50) NOT NULL UNIQUE,
layout_mode VARCHAR(50) NOT NULL,
charts_json TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
@@ -0,0 +1,59 @@
ALTER TABLE chart_workspace
ADD COLUMN name VARCHAR(120) NOT NULL DEFAULT 'Workspace principal';
ALTER TABLE chart_workspace
ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
ALTER TABLE chart_workspace
ADD COLUMN is_default BOOLEAN NOT NULL DEFAULT TRUE;
CREATE TABLE chart_workspace_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope VARCHAR(50) NOT NULL,
name VARCHAR(120) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
layout_mode VARCHAR(50) NOT NULL,
charts_json TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
INSERT INTO chart_workspace_new (
id,
scope,
name,
sort_order,
is_default,
layout_mode,
charts_json,
created_at,
updated_at
)
SELECT
id,
scope,
name,
sort_order,
is_default,
layout_mode,
charts_json,
created_at,
updated_at
FROM chart_workspace;
DROP TABLE chart_workspace;
ALTER TABLE chart_workspace_new
RENAME TO chart_workspace;
CREATE INDEX idx_chart_workspace_scope_sort
ON chart_workspace (scope, sort_order, id);
CREATE UNIQUE INDEX idx_chart_workspace_scope_default
ON chart_workspace (scope)
WHERE is_default = TRUE;