Compare commits
20 Commits
70e95a2b55
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 36b105c937 | |||
| 6ccef04914 | |||
| 6ef1e83e63 | |||
| ba52c1516b | |||
| 109b226448 | |||
| 8138ecc1d2 | |||
| 460e57bc3a | |||
| 4659620072 | |||
| df673e3cb4 | |||
| 727278d644 | |||
| 0a33f42502 | |||
| 6fefa2542e | |||
| d86ec39b08 | |||
| af709852ac | |||
| db75744305 | |||
| cb63d6a237 | |||
| 5e30160b31 | |||
| 315f7b61f8 | |||
| 809b79b6f6 | |||
| f3d08bd837 |
@@ -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;
|
||||
}
|
||||
}
|
||||
+39
-5
@@ -77,21 +77,55 @@ public class AcquisitionPlanBuilder {
|
||||
) {
|
||||
Integer address = sensor.getModbusAddress();
|
||||
|
||||
if (address == null || address < 0) {
|
||||
if (address == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exterior/meteo climate block
|
||||
if (address >= 10 && address <= 22) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (address < 100 || address > 899) {
|
||||
return false;
|
||||
// Main greenhouse climate blocks:
|
||||
// E1: 100-126
|
||||
// E2: 140-166
|
||||
// E3: 180-206
|
||||
// ...
|
||||
// stride = 40
|
||||
if (address >= 100 && address <= 899) {
|
||||
int greenhouseNumber = ((address - 100) / 40) + 1;
|
||||
int offsetInBlock = (address - 100) % 40;
|
||||
|
||||
return greenhouseNumber >= 1
|
||||
&& greenhouseNumber <= greenhouseCount
|
||||
&& offsetInBlock >= 0
|
||||
&& offsetInBlock <= 26;
|
||||
}
|
||||
|
||||
int greenhouseNumber = ((address - 100) / 40) + 1;
|
||||
// Soil sensors:
|
||||
// Humidade solo 1-36: 3200-3235
|
||||
// Temperatura solo 1-36: 3236-3271
|
||||
if (address >= 3200 && address <= 3271) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return greenhouseNumber >= 1 && greenhouseNumber <= greenhouseCount;
|
||||
// Computed climate values:
|
||||
// DPV Estufa 1-20: -121 to -140
|
||||
// Hum. Absoluta Estufa 1-20: -141 to -160
|
||||
if (address >= -160 && address <= -121) {
|
||||
int absoluteIndex = Math.abs(address);
|
||||
|
||||
int greenhouseNumber;
|
||||
if (absoluteIndex >= 121 && absoluteIndex <= 140) {
|
||||
greenhouseNumber = absoluteIndex - 120;
|
||||
} else {
|
||||
greenhouseNumber = absoluteIndex - 140;
|
||||
}
|
||||
|
||||
return greenhouseNumber >= 1 && greenhouseNumber <= greenhouseCount;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean belongsToEnabledIrrigationRange(
|
||||
|
||||
+19
@@ -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();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+79
-2
@@ -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());
|
||||
|
||||
|
||||
+70
-9
@@ -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");
|
||||
}
|
||||
}
|
||||
+24
@@ -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");
|
||||
}
|
||||
}
|
||||
+23
@@ -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
|
||||
) {
|
||||
}
|
||||
+1
-1
@@ -15,6 +15,6 @@ public class SensorDefinitionImportController {
|
||||
|
||||
@PostMapping("/api/sensor-definition-import/run")
|
||||
public SensorDefinitionImportResult runImport() {
|
||||
return importService.importSensorMap();
|
||||
return importService.importSensorDefinitions();
|
||||
}
|
||||
}
|
||||
+56
-40
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+35
@@ -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
|
||||
) {}
|
||||
+8
@@ -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("*");
|
||||
}
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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
|
||||
) {
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user