weather api support
This commit is contained in:
@@ -2,12 +2,13 @@ package com.litoralregas.backend;
|
||||
|
||||
import com.litoralregas.backend.acquisition.scheduler.AcquisitionSchedulerProperties;
|
||||
import com.litoralregas.backend.modbus.ModbusConnectionProperties;
|
||||
import com.litoralregas.backend.weather.WeatherApiProperties;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableConfigurationProperties({ModbusConnectionProperties.class, AcquisitionSchedulerProperties.class})
|
||||
@EnableConfigurationProperties({ModbusConnectionProperties.class, AcquisitionSchedulerProperties.class, WeatherApiProperties.class})
|
||||
public class BackendApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
+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,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
|
||||
) {}
|
||||
@@ -1,5 +1,6 @@
|
||||
server:
|
||||
port: 18450
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: backend
|
||||
@@ -21,13 +22,26 @@ litoralregas:
|
||||
runtime:
|
||||
mode: Local
|
||||
controller-name: Estufa_Litoral
|
||||
|
||||
modbus:
|
||||
host: 198.19.0.176
|
||||
port: 533
|
||||
timeout-millis: 500
|
||||
max-attempts: 3
|
||||
retry-delay-millis: 1000
|
||||
|
||||
acquisition:
|
||||
scheduler:
|
||||
enabled: true
|
||||
fixed-delay-millis: 3000
|
||||
fixed-delay-millis: 3000
|
||||
|
||||
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
|
||||
);
|
||||
Reference in New Issue
Block a user