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.acquisition.scheduler.AcquisitionSchedulerProperties;
|
||||||
import com.litoralregas.backend.modbus.ModbusConnectionProperties;
|
import com.litoralregas.backend.modbus.ModbusConnectionProperties;
|
||||||
|
import com.litoralregas.backend.weather.WeatherApiProperties;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableConfigurationProperties({ModbusConnectionProperties.class, AcquisitionSchedulerProperties.class})
|
@EnableConfigurationProperties({ModbusConnectionProperties.class, AcquisitionSchedulerProperties.class, WeatherApiProperties.class})
|
||||||
public class BackendApplication {
|
public class BackendApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -74,9 +74,15 @@ public class BlockPollingService {
|
|||||||
Integer rawValue = rawValueForSensor(sensor, block, result);
|
Integer rawValue = rawValueForSensor(sensor, block, result);
|
||||||
Object value = convertValue(sensor, rawValue);
|
Object value = convertValue(sensor, rawValue);
|
||||||
|
|
||||||
|
if (value == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
telemetryCache.put(new TelemetrySnapshot(
|
telemetryCache.put(new TelemetrySnapshot(
|
||||||
sensor.getId(),
|
sensor.getId(),
|
||||||
|
sensor.getKey(),
|
||||||
sensor.getName(),
|
sensor.getName(),
|
||||||
|
sensor.getCategory(),
|
||||||
sensor.getModbusAddress(),
|
sensor.getModbusAddress(),
|
||||||
sensor.getBitOffset(),
|
sensor.getBitOffset(),
|
||||||
rawValue,
|
rawValue,
|
||||||
@@ -121,21 +127,68 @@ public class BlockPollingService {
|
|||||||
return result.values().get(offset);
|
return result.values().get(offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Object convertValue(SensorDefinition sensor, Integer rawValue) {
|
private Object convertValue(
|
||||||
|
SensorDefinition sensor,
|
||||||
|
Integer rawValue
|
||||||
|
) {
|
||||||
if (sensor.getValueType() == SensorValueType.BOOLEAN) {
|
if (sensor.getValueType() == SensorValueType.BOOLEAN) {
|
||||||
|
|
||||||
Integer bitOffset = sensor.getBitOffset();
|
Integer bitOffset = sensor.getBitOffset();
|
||||||
|
|
||||||
if (bitOffset == null) {
|
if (bitOffset == null) {
|
||||||
throw new IllegalStateException("BOOLEAN sensor requires bitOffset.");
|
throw new IllegalStateException(
|
||||||
|
"BOOLEAN sensor requires bitOffset."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ((rawValue >> bitOffset) & 1) == 1;
|
return ((rawValue >> bitOffset) & 1) == 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sensor.getValueType() == SensorValueType.DECIMAL) {
|
int decodedRawValue = decodeSignedRawValue(
|
||||||
return rawValue / Math.pow(10, sensor.getDecimalPlaces());
|
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;
|
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();
|
Integer address = sensor.getModbusAddress();
|
||||||
|
|
||||||
if (address == null || address < 0) {
|
if (address == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exterior/meteo climate block
|
||||||
if (address >= 10 && address <= 22) {
|
if (address >= 10 && address <= 22) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (address < 100 || address > 899) {
|
// Main greenhouse climate blocks:
|
||||||
return false;
|
// 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(
|
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
|
@Configuration
|
||||||
public class AcquisitionSchedulerConfig {
|
public class AcquisitionSchedulerConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean(name = "acquisitionTaskScheduler")
|
||||||
public TaskScheduler acquisitionTaskScheduler() {
|
public TaskScheduler acquisitionTaskScheduler() {
|
||||||
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
||||||
scheduler.setPoolSize(1);
|
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.polling.AcquisitionPollResult;
|
||||||
import com.litoralregas.backend.acquisition.block.BlockPollingService;
|
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 jakarta.annotation.PostConstruct;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.scheduling.TaskScheduler;
|
import org.springframework.scheduling.TaskScheduler;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -15,19 +27,44 @@ public class AcquisitionSchedulerService {
|
|||||||
private final BlockPollingService blockPollingService;
|
private final BlockPollingService blockPollingService;
|
||||||
private final AcquisitionSchedulerProperties properties;
|
private final AcquisitionSchedulerProperties properties;
|
||||||
private final TaskScheduler taskScheduler;
|
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 AcquisitionRuntimeStatus runtimeStatus = new AcquisitionRuntimeStatus();
|
||||||
|
|
||||||
private final AtomicBoolean polling = new AtomicBoolean(false);
|
private final AtomicBoolean polling = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
private final ClimateModuleWebSocketPublisher climateModuleWebSocketPublisher;
|
||||||
|
private final ClimateModuleService climateModuleService;
|
||||||
|
|
||||||
public AcquisitionSchedulerService(
|
public AcquisitionSchedulerService(
|
||||||
BlockPollingService blockPollingService,
|
BlockPollingService blockPollingService,
|
||||||
AcquisitionSchedulerProperties properties,
|
AcquisitionSchedulerProperties properties,
|
||||||
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.blockPollingService = blockPollingService;
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
this.taskScheduler = taskScheduler;
|
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
|
@PostConstruct
|
||||||
@@ -39,7 +76,7 @@ public class AcquisitionSchedulerService {
|
|||||||
|
|
||||||
System.out.println("Starting acquisition scheduler.");
|
System.out.println("Starting acquisition scheduler.");
|
||||||
|
|
||||||
taskScheduler.scheduleWithFixedDelay(
|
taskScheduler.scheduleAtFixedRate(
|
||||||
this::safePoll,
|
this::safePoll,
|
||||||
properties.getFixedDelayMillis()
|
properties.getFixedDelayMillis()
|
||||||
);
|
);
|
||||||
@@ -59,12 +96,52 @@ public class AcquisitionSchedulerService {
|
|||||||
runtimeStatus.setLastStartedAt(Instant.now());
|
runtimeStatus.setLastStartedAt(Instant.now());
|
||||||
runtimeStatus.setLastError(null);
|
runtimeStatus.setLastError(null);
|
||||||
|
|
||||||
|
long started = System.currentTimeMillis();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
AcquisitionPollResult result = blockPollingService.pollOnceByBlocks();
|
AcquisitionPollResult result = blockPollingService.pollOnceByBlocks();
|
||||||
|
System.out.println(
|
||||||
|
"pollOnceByBlocks took "
|
||||||
|
+ (System.currentTimeMillis() - started)
|
||||||
|
+ " ms"
|
||||||
|
);
|
||||||
|
|
||||||
|
long wsStart = System.currentTimeMillis();
|
||||||
runtimeStatus.setLastSuccessfulReads(result.successfulReads());
|
runtimeStatus.setLastSuccessfulReads(result.successfulReads());
|
||||||
runtimeStatus.setLastFailedReads(result.failedReads());
|
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) {
|
} catch (Exception exception) {
|
||||||
runtimeStatus.setLastError(exception.getMessage());
|
runtimeStatus.setLastError(exception.getMessage());
|
||||||
|
|
||||||
|
|||||||
+70
-9
@@ -1,7 +1,5 @@
|
|||||||
package com.litoralregas.backend.acquisition.telemetry;
|
package com.litoralregas.backend.acquisition.telemetry;
|
||||||
|
|
||||||
import com.litoralregas.backend.acquisition.telemetry.TelemetryCache;
|
|
||||||
import com.litoralregas.backend.acquisition.telemetry.TelemetrySnapshot;
|
|
||||||
import com.litoralregas.backend.modbus.LrModbusClient;
|
import com.litoralregas.backend.modbus.LrModbusClient;
|
||||||
import com.litoralregas.backend.modbus.ModbusReadResult;
|
import com.litoralregas.backend.modbus.ModbusReadResult;
|
||||||
import com.litoralregas.backend.modbus.ModbusUnit;
|
import com.litoralregas.backend.modbus.ModbusUnit;
|
||||||
@@ -32,11 +30,19 @@ public class SensorTelemetryReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public TelemetrySnapshot readSensor(Integer sensorId) {
|
public TelemetrySnapshot readSensor(Integer sensorId) {
|
||||||
SensorDefinition sensorDefinition = sensorDefinitionRepository.findById(sensorId)
|
|
||||||
.orElseThrow(() -> new EntityNotFoundException("Sensor definition not found: " + sensorId));
|
SensorDefinition sensorDefinition =
|
||||||
|
sensorDefinitionRepository.findById(sensorId)
|
||||||
|
.orElseThrow(() ->
|
||||||
|
new EntityNotFoundException(
|
||||||
|
"Sensor definition not found: " + sensorId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
if (sensorDefinition.getSourceType() != SensorSourceType.MODBUS) {
|
if (sensorDefinition.getSourceType() != SensorSourceType.MODBUS) {
|
||||||
throw new IllegalArgumentException("Only MODBUS sensors can be read directly.");
|
throw new IllegalArgumentException(
|
||||||
|
"Only MODBUS sensors can be read directly."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ModbusReadResult result = modbusClient.readInputRegisters(
|
ModbusReadResult result = modbusClient.readInputRegisters(
|
||||||
@@ -46,11 +52,20 @@ public class SensorTelemetryReader {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Integer rawValue = result.values().getFirst();
|
Integer rawValue = result.values().getFirst();
|
||||||
|
|
||||||
Object value = convertValue(sensorDefinition, rawValue);
|
Object value = convertValue(sensorDefinition, rawValue);
|
||||||
|
|
||||||
|
if (value == null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Sensor value is invalid: " + sensorDefinition.getKey()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
TelemetrySnapshot snapshot = new TelemetrySnapshot(
|
TelemetrySnapshot snapshot = new TelemetrySnapshot(
|
||||||
sensorDefinition.getId(),
|
sensorDefinition.getId(),
|
||||||
|
sensorDefinition.getKey(),
|
||||||
sensorDefinition.getName(),
|
sensorDefinition.getName(),
|
||||||
|
sensorDefinition.getCategory(),
|
||||||
sensorDefinition.getModbusAddress(),
|
sensorDefinition.getModbusAddress(),
|
||||||
sensorDefinition.getBitOffset(),
|
sensorDefinition.getBitOffset(),
|
||||||
rawValue,
|
rawValue,
|
||||||
@@ -64,21 +79,67 @@ public class SensorTelemetryReader {
|
|||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Object convertValue(SensorDefinition sensorDefinition, Integer rawValue) {
|
private Object convertValue(
|
||||||
|
SensorDefinition sensorDefinition,
|
||||||
|
Integer rawValue
|
||||||
|
) {
|
||||||
if (sensorDefinition.getValueType() == SensorValueType.BOOLEAN) {
|
if (sensorDefinition.getValueType() == SensorValueType.BOOLEAN) {
|
||||||
Integer bitOffset = sensorDefinition.getBitOffset();
|
Integer bitOffset = sensorDefinition.getBitOffset();
|
||||||
|
|
||||||
if (bitOffset == null) {
|
if (bitOffset == null) {
|
||||||
throw new IllegalStateException("BOOLEAN sensor requires bitOffset.");
|
throw new IllegalStateException(
|
||||||
|
"BOOLEAN sensor requires bitOffset."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ((rawValue >> bitOffset) & 1) == 1;
|
return ((rawValue >> bitOffset) & 1) == 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sensorDefinition.getValueType() == SensorValueType.DECIMAL) {
|
int decodedRawValue = decodeSignedRawValue(
|
||||||
return rawValue / Math.pow(10, sensorDefinition.getDecimalPlaces());
|
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;
|
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(
|
public record TelemetrySnapshot(
|
||||||
Integer sensorId,
|
Integer sensorId,
|
||||||
|
String key,
|
||||||
String name,
|
String name,
|
||||||
|
String category,
|
||||||
Integer modbusAddress,
|
Integer modbusAddress,
|
||||||
Integer bitOffset,
|
Integer bitOffset,
|
||||||
Integer rawValue,
|
Integer rawValue,
|
||||||
|
|||||||
@@ -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;
|
package com.litoralregas.backend.sensor;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -11,10 +12,16 @@ public class SensorDefinition {
|
|||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Integer id;
|
private Integer id;
|
||||||
|
|
||||||
|
@Column(unique = true, nullable = false)
|
||||||
|
private String key;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@Column(name = "modbus_address", nullable = false)
|
@Column(nullable = false)
|
||||||
|
private String category;
|
||||||
|
|
||||||
|
@Column(name = "modbus_address")
|
||||||
private Integer modbusAddress;
|
private Integer modbusAddress;
|
||||||
|
|
||||||
@Column(name = "bit_offset")
|
@Column(name = "bit_offset")
|
||||||
@@ -29,9 +36,6 @@ public class SensorDefinition {
|
|||||||
@Column(name = "decimal_places", nullable = false)
|
@Column(name = "decimal_places", nullable = false)
|
||||||
private Integer decimalPlaces;
|
private Integer decimalPlaces;
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private String category;
|
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(name = "source_type", nullable = false)
|
@Column(name = "source_type", nullable = false)
|
||||||
private SensorSourceType sourceType;
|
private SensorSourceType sourceType;
|
||||||
@@ -45,38 +49,75 @@ public class SensorDefinition {
|
|||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private Instant createdAt;
|
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() {
|
protected SensorDefinition() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public SensorDefinition(
|
public SensorDefinition(
|
||||||
|
String key,
|
||||||
String name,
|
String name,
|
||||||
|
String category,
|
||||||
Integer modbusAddress,
|
Integer modbusAddress,
|
||||||
Integer bitOffset,
|
Integer bitOffset,
|
||||||
SensorValueType valueType,
|
SensorValueType valueType,
|
||||||
String unit,
|
String unit,
|
||||||
Integer decimalPlaces,
|
Integer decimalPlaces,
|
||||||
String category,
|
|
||||||
SensorSourceType sourceType,
|
SensorSourceType sourceType,
|
||||||
Integer pollingIntervalSeconds,
|
Integer pollingIntervalSeconds,
|
||||||
Boolean enabled
|
Boolean enabled,
|
||||||
|
Double scaleFactor,
|
||||||
|
Boolean signed,
|
||||||
|
Double validMin,
|
||||||
|
Double validMax
|
||||||
) {
|
) {
|
||||||
|
this.key = key;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
|
this.category = category;
|
||||||
this.modbusAddress = modbusAddress;
|
this.modbusAddress = modbusAddress;
|
||||||
this.bitOffset = bitOffset;
|
this.bitOffset = bitOffset;
|
||||||
this.valueType = valueType;
|
this.valueType = valueType;
|
||||||
this.unit = unit;
|
this.unit = unit;
|
||||||
this.decimalPlaces = decimalPlaces;
|
this.decimalPlaces = decimalPlaces;
|
||||||
this.category = category;
|
|
||||||
this.sourceType = sourceType;
|
this.sourceType = sourceType;
|
||||||
this.pollingIntervalSeconds = pollingIntervalSeconds;
|
this.pollingIntervalSeconds = pollingIntervalSeconds;
|
||||||
this.enabled = enabled;
|
this.enabled = enabled;
|
||||||
this.createdAt = Instant.now();
|
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() {
|
public Integer getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getKey() {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setKey(String key) {
|
||||||
|
this.key = key;
|
||||||
|
}
|
||||||
|
|
||||||
public String getName() {
|
public String getName() {
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
@@ -85,6 +126,14 @@ public class SensorDefinition {
|
|||||||
this.name = name;
|
this.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getCategory() {
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCategory(String category) {
|
||||||
|
this.category = category;
|
||||||
|
}
|
||||||
|
|
||||||
public Integer getModbusAddress() {
|
public Integer getModbusAddress() {
|
||||||
return modbusAddress;
|
return modbusAddress;
|
||||||
}
|
}
|
||||||
@@ -125,14 +174,6 @@ public class SensorDefinition {
|
|||||||
this.decimalPlaces = decimalPlaces;
|
this.decimalPlaces = decimalPlaces;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getCategory() {
|
|
||||||
return category;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setCategory(String category) {
|
|
||||||
this.category = category;
|
|
||||||
}
|
|
||||||
|
|
||||||
public SensorSourceType getSourceType() {
|
public SensorSourceType getSourceType() {
|
||||||
return sourceType;
|
return sourceType;
|
||||||
}
|
}
|
||||||
@@ -164,4 +205,36 @@ public class SensorDefinition {
|
|||||||
public void setCreatedAt(Instant createdAt) {
|
public void setCreatedAt(Instant createdAt) {
|
||||||
this.createdAt = 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;
|
package com.litoralregas.backend.sensor;
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface SensorDefinitionRepository extends JpaRepository<SensorDefinition, Integer> {
|
public interface SensorDefinitionRepository extends JpaRepository<SensorDefinition, Integer> {
|
||||||
|
|
||||||
@@ -11,4 +14,21 @@ public interface SensorDefinitionRepository extends JpaRepository<SensorDefiniti
|
|||||||
List<SensorDefinition> findByEnabledTrueOrderByNameAsc();
|
List<SensorDefinition> findByEnabledTrueOrderByNameAsc();
|
||||||
|
|
||||||
List<SensorDefinition> findAllByOrderByNameAsc();
|
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")
|
@PostMapping("/api/sensor-definition-import/run")
|
||||||
public SensorDefinitionImportResult runImport() {
|
public SensorDefinitionImportResult runImport() {
|
||||||
return importService.importSensorMap();
|
return importService.importSensorDefinitions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+56
-40
@@ -1,93 +1,109 @@
|
|||||||
package com.litoralregas.backend.sensor.importer;
|
package com.litoralregas.backend.sensor.importer;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.litoralregas.backend.sensor.SensorDefinition;
|
import com.litoralregas.backend.sensor.SensorDefinition;
|
||||||
import com.litoralregas.backend.sensor.SensorDefinitionRepository;
|
import com.litoralregas.backend.sensor.SensorDefinitionRepository;
|
||||||
|
import com.litoralregas.backend.sensor.SensorSourceType;
|
||||||
|
import com.litoralregas.backend.sensor.SensorValueType;
|
||||||
import com.litoralregas.backend.sensor.dto.SensorDefinitionImportResult;
|
import com.litoralregas.backend.sensor.dto.SensorDefinitionImportResult;
|
||||||
import com.litoralregas.backend.sensor.dto.SensorDefinitionImportRow;
|
|
||||||
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.io.InputStream;
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class SensorDefinitionImportService {
|
public class SensorDefinitionImportService {
|
||||||
|
|
||||||
private static final String SENSOR_MAP_PATH = "config/sensor-map.txt";
|
private static final String SENSOR_DEFINITIONS_PATH =
|
||||||
private static final int DEFAULT_POLLING_INTERVAL_SECONDS = 2;
|
"config/sensor-definitions.json";
|
||||||
|
|
||||||
private final SensorDefinitionMapParser parser;
|
|
||||||
private final SensorDefinitionRepository repository;
|
private final SensorDefinitionRepository repository;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
public SensorDefinitionImportService(
|
public SensorDefinitionImportService(
|
||||||
SensorDefinitionMapParser parser,
|
SensorDefinitionRepository repository,
|
||||||
SensorDefinitionRepository repository
|
ObjectMapper objectMapper
|
||||||
) {
|
) {
|
||||||
this.parser = parser;
|
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public SensorDefinitionImportResult importSensorMap() {
|
public SensorDefinitionImportResult importSensorDefinitions() {
|
||||||
List<String> lines = readSensorMapLines();
|
|
||||||
|
SensorDefinitionsFile file = readDefinitionsFile();
|
||||||
|
|
||||||
int imported = 0;
|
int imported = 0;
|
||||||
int skippedExisting = 0;
|
int skippedExisting = 0;
|
||||||
int skippedBlank = 0;
|
|
||||||
|
|
||||||
for (String line : lines) {
|
for (SensorDefinitionConfig config : file.sensors()) {
|
||||||
if (line == null || line.isBlank()) {
|
|
||||||
skippedBlank++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
SensorDefinitionImportRow row = parser.parseLine(line)
|
if (repository.findByName(config.name()).isPresent()) {
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Invalid empty sensor row."));
|
|
||||||
|
|
||||||
if (repository.existsByName(row.name())) {
|
|
||||||
skippedExisting++;
|
skippedExisting++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
SensorDefinition sensorDefinition = new SensorDefinition(
|
SensorDefinition sensorDefinition = new SensorDefinition(
|
||||||
row.name(),
|
config.key(),
|
||||||
row.modbusAddress(),
|
config.name(),
|
||||||
row.bitOffset(),
|
config.category(),
|
||||||
row.valueType(),
|
config.modbus().address(),
|
||||||
row.unit(),
|
config.modbus().bitOffset(),
|
||||||
row.decimalPlaces(),
|
SensorValueType.valueOf(config.valueType()),
|
||||||
row.category(),
|
config.unit(),
|
||||||
row.sourceType(),
|
config.decimalPlaces(),
|
||||||
DEFAULT_POLLING_INTERVAL_SECONDS,
|
SensorSourceType.MODBUS,
|
||||||
true
|
config.pollingIntervalSeconds(),
|
||||||
|
config.enabled(),
|
||||||
|
|
||||||
|
config.scaleFactor(),
|
||||||
|
config.signed(),
|
||||||
|
config.validMin(),
|
||||||
|
config.validMax()
|
||||||
);
|
);
|
||||||
|
|
||||||
repository.save(sensorDefinition);
|
repository.save(sensorDefinition);
|
||||||
|
|
||||||
imported++;
|
imported++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new SensorDefinitionImportResult(
|
return new SensorDefinitionImportResult(
|
||||||
lines.size(),
|
file.sensorCount(),
|
||||||
imported,
|
imported,
|
||||||
skippedExisting,
|
skippedExisting,
|
||||||
skippedBlank
|
0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<String> readSensorMapLines() {
|
private SensorDefinitionsFile readDefinitionsFile() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ClassPathResource resource = new ClassPathResource(SENSOR_MAP_PATH);
|
|
||||||
|
ClassPathResource resource =
|
||||||
|
new ClassPathResource(SENSOR_DEFINITIONS_PATH);
|
||||||
|
|
||||||
if (!resource.exists()) {
|
if (!resource.exists()) {
|
||||||
throw new IllegalStateException("Sensor map file not found: " + SENSOR_MAP_PATH);
|
throw new IllegalStateException(
|
||||||
|
"Sensor definitions file not found: "
|
||||||
|
+ SENSOR_DEFINITIONS_PATH
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try (InputStream inputStream = resource.getInputStream()) {
|
||||||
|
|
||||||
|
return objectMapper.readValue(
|
||||||
|
inputStream,
|
||||||
|
SensorDefinitionsFile.class
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return resource.getContentAsString(StandardCharsets.UTF_8)
|
|
||||||
.lines()
|
|
||||||
.toList();
|
|
||||||
} catch (Exception exception) {
|
} catch (Exception exception) {
|
||||||
throw new IllegalStateException("Failed to read sensor map file.", exception);
|
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Failed to load sensor definitions.",
|
||||||
|
exception
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
package com.litoralregas.backend.sensor.importer;
|
|
||||||
|
|
||||||
import com.litoralregas.backend.sensor.SensorSourceType;
|
|
||||||
import com.litoralregas.backend.sensor.SensorValueType;
|
|
||||||
import com.litoralregas.backend.sensor.dto.SensorDefinitionImportRow;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
public class SensorDefinitionMapParser {
|
|
||||||
|
|
||||||
public Optional<SensorDefinitionImportRow> parseLine(String line) {
|
|
||||||
if (line == null || line.isBlank()) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
String[] parts = line.split("\\*");
|
|
||||||
|
|
||||||
if (parts.length != 5) {
|
|
||||||
throw new IllegalArgumentException("Invalid sensor map line: " + line);
|
|
||||||
}
|
|
||||||
|
|
||||||
String name = parts[0].trim();
|
|
||||||
String addressPart = parts[1].trim();
|
|
||||||
Integer decimalPlaces = Integer.parseInt(parts[2].trim());
|
|
||||||
String unit = normalizeUnit(parts[3].trim());
|
|
||||||
String category = mapCategory(parts[4].trim());
|
|
||||||
|
|
||||||
ParsedAddress parsedAddress = parseAddress(addressPart);
|
|
||||||
|
|
||||||
SensorSourceType sourceType = parsedAddress.modbusAddress() < 0
|
|
||||||
? SensorSourceType.CALCULATED
|
|
||||||
: SensorSourceType.MODBUS;
|
|
||||||
|
|
||||||
SensorValueType valueType = parsedAddress.bitOffset() != null
|
|
||||||
? SensorValueType.BOOLEAN
|
|
||||||
: decimalPlaces > 0 ? SensorValueType.DECIMAL : SensorValueType.INTEGER;
|
|
||||||
|
|
||||||
return Optional.of(new SensorDefinitionImportRow(
|
|
||||||
name,
|
|
||||||
parsedAddress.modbusAddress(),
|
|
||||||
parsedAddress.bitOffset(),
|
|
||||||
valueType,
|
|
||||||
unit,
|
|
||||||
decimalPlaces,
|
|
||||||
category,
|
|
||||||
sourceType
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private ParsedAddress parseAddress(String addressPart) {
|
|
||||||
if (addressPart.contains(",")) {
|
|
||||||
String[] addressParts = addressPart.split(",");
|
|
||||||
|
|
||||||
if (addressParts.length != 2) {
|
|
||||||
throw new IllegalArgumentException("Invalid bit address: " + addressPart);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ParsedAddress(
|
|
||||||
Integer.parseInt(addressParts[0].trim()),
|
|
||||||
Integer.parseInt(addressParts[1].trim())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ParsedAddress(
|
|
||||||
Integer.parseInt(addressPart),
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String normalizeUnit(String unit) {
|
|
||||||
if (unit == null || unit.isBlank() || unit.equalsIgnoreCase("SU")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String mapCategory(String categoryCode) {
|
|
||||||
return switch (categoryCode.toLowerCase()) {
|
|
||||||
case "c" -> "CLIMATE";
|
|
||||||
case "r" -> "IRRIGATION";
|
|
||||||
case "i" -> "LIGHTING";
|
|
||||||
case "h" -> "HYDRO";
|
|
||||||
case "a" -> "AEROPONICS";
|
|
||||||
default -> "UNKNOWN";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private record ParsedAddress(
|
|
||||||
Integer modbusAddress,
|
|
||||||
Integer bitOffset
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+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:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: backend
|
name: backend
|
||||||
@@ -16,13 +19,38 @@ spring:
|
|||||||
locations: classpath:db/migration
|
locations: classpath:db/migration
|
||||||
|
|
||||||
litoralregas:
|
litoralregas:
|
||||||
|
runtime:
|
||||||
|
mode: Local
|
||||||
|
controller-name: Estufa_Litoral
|
||||||
|
|
||||||
modbus:
|
modbus:
|
||||||
host: 198.19.0.176
|
host: 198.19.0.176
|
||||||
port: 533
|
port: 533
|
||||||
timeout-millis: 500
|
timeout-millis: 500
|
||||||
max-attempts: 3
|
max-attempts: 3
|
||||||
retry-delay-millis: 1000
|
retry-delay-millis: 1000
|
||||||
|
|
||||||
acquisition:
|
acquisition:
|
||||||
scheduler:
|
scheduler:
|
||||||
enabled: true
|
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 (
|
CREATE TABLE sensor_definition (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
||||||
|
key VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
modbus_address INTEGER NOT NULL,
|
|
||||||
bit_offset INTEGER,
|
|
||||||
value_type VARCHAR(50) NOT NULL,
|
|
||||||
unit VARCHAR(50),
|
|
||||||
decimal_places INTEGER NOT NULL DEFAULT 0,
|
|
||||||
category VARCHAR(100) NOT NULL,
|
category VARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
|
modbus_address INTEGER,
|
||||||
|
|
||||||
|
bit_offset INTEGER,
|
||||||
|
|
||||||
|
value_type VARCHAR(50) NOT NULL,
|
||||||
|
|
||||||
|
unit VARCHAR(50),
|
||||||
|
|
||||||
|
decimal_places INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
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,
|
source_type VARCHAR(50) NOT NULL,
|
||||||
|
|
||||||
polling_interval_seconds INTEGER NOT NULL DEFAULT 1,
|
polling_interval_seconds INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
|
||||||
created_at TIMESTAMP NOT NULL
|
created_at TIMESTAMP NOT NULL
|
||||||
);
|
);
|
||||||
@@ -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