weather api support

This commit is contained in:
litoral05
2026-05-25 17:19:47 +01:00
parent d86ec39b08
commit 6fefa2542e
16 changed files with 578 additions and 2 deletions
@@ -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) {
@@ -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,45 @@
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
public class WeatherController {
private final WeatherService weatherService;
public WeatherController(WeatherService weatherService) {
this.weatherService = weatherService;
}
@GetMapping("/api/weather/forecast")
public WeatherForecastResponse getForecast(
@RequestParam(defaultValue = "7") int days
) {
return weatherService.getConfiguredForecast(days);
}
@GetMapping("/api/weather/location")
public WeatherConfiguredLocationResponse getLocation() {
return weatherService.getConfiguredLocation();
}
@PutMapping("/api/weather/location")
public WeatherConfiguredLocationResponse updateLocation(
@RequestBody WeatherLocationUpdateRequest request
) {
return weatherService.updateConfiguredLocation(
request.latitude(),
request.longitude(),
request.locationName()
);
}
@GetMapping("/api/weather/search")
public JsonNode search(@RequestParam String query) {
return weatherService.search(query);
}
}
@@ -0,0 +1,256 @@
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 current = payload.path("current");
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");
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"),
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")
),
new WeatherCurrentDto(
doubleOrNull(current, "temp_c"),
doubleOrNull(current, "feelslike_c"),
intOrNull(current, "humidity"),
doubleOrNull(current, "precip_mm"),
doubleOrNull(current, "wind_kph"),
doubleOrNull(current, "wind_degree"),
textOrNull(current, "wind_dir"),
doubleOrNull(current, "pressure_mb"),
doubleOrNull(current, "uv"),
toCondition(current.path("condition"))
),
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."));
}
}
@@ -0,0 +1,76 @@
package com.litoralregas.backend.weather;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "weather_settings")
public class WeatherSettings {
@Id
private Integer id;
@Column(nullable = false)
private boolean enabled;
@Column(nullable = false)
private double latitude;
@Column(nullable = false)
private double longitude;
@Column(name = "location_name", nullable = false)
private String locationName;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public double getLatitude() {
return latitude;
}
public void setLatitude(double latitude) {
this.latitude = latitude;
}
public double getLongitude() {
return longitude;
}
public void setLongitude(double longitude) {
this.longitude = longitude;
}
public String getLocationName() {
return locationName;
}
public void setLocationName(String locationName) {
this.locationName = locationName;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
}
@@ -0,0 +1,7 @@
package com.litoralregas.backend.weather;
import org.springframework.data.jpa.repository.JpaRepository;
public interface WeatherSettingsRepository
extends JpaRepository<WeatherSettings, Integer> {
}
@@ -0,0 +1,7 @@
package com.litoralregas.backend.weather.dto;
public record WeatherConditionDto(
String text,
String icon,
Integer code
) {}
@@ -0,0 +1,8 @@
package com.litoralregas.backend.weather.dto;
public record WeatherConfiguredLocationResponse(
boolean enabled,
double latitude,
double longitude,
String locationName
) {}
@@ -0,0 +1,14 @@
package com.litoralregas.backend.weather.dto;
public record WeatherCurrentDto(
Double temperatureC,
Double feelsLikeC,
Integer humidity,
Double precipitationMm,
Double windKph,
Double windDegree,
String windDirection,
Double pressureMb,
Double uv,
WeatherConditionDto condition
) {}
@@ -0,0 +1,15 @@
package com.litoralregas.backend.weather.dto;
public record WeatherDailyDto(
String date,
Double maxTemperatureC,
Double minTemperatureC,
Double averageTemperatureC,
Double totalPrecipitationMm,
Integer dailyRainChance,
Double maxWindKph,
Double uv,
String sunrise,
String sunset,
WeatherConditionDto condition
) {}
@@ -0,0 +1,9 @@
package com.litoralregas.backend.weather.dto;
import java.util.List;
public record WeatherForecastResponse(
WeatherLocationDto location,
WeatherCurrentDto current,
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
) {}
+15 -1
View File
@@ -1,5 +1,6 @@
server: server:
port: 18450 port: 18450
spring: spring:
application: application:
name: backend name: backend
@@ -21,13 +22,26 @@ litoralregas:
runtime: runtime:
mode: Local mode: Local
controller-name: Estufa_Litoral 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
weather:
enabled: true
latitude: 40.4289
longitude: -8.7375
location-name: Mira
weather:
api-key: 0aa355536b6c469eb4b82226262505
base-url: https://api.weatherapi.com/v1
cache-minutes: 720
@@ -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
);