diff --git a/src/main/java/com/litoralregas/backend/BackendApplication.java b/src/main/java/com/litoralregas/backend/BackendApplication.java index 529a210..a703be7 100644 --- a/src/main/java/com/litoralregas/backend/BackendApplication.java +++ b/src/main/java/com/litoralregas/backend/BackendApplication.java @@ -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) { diff --git a/src/main/java/com/litoralregas/backend/weather/WeatherApiProperties.java b/src/main/java/com/litoralregas/backend/weather/WeatherApiProperties.java new file mode 100644 index 0000000..b446f11 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/weather/WeatherApiProperties.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/weather/WeatherConditionMapper.java b/src/main/java/com/litoralregas/backend/weather/WeatherConditionMapper.java new file mode 100644 index 0000000..c8ac282 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/weather/WeatherConditionMapper.java @@ -0,0 +1,47 @@ +package com.litoralregas.backend.weather; + +import java.util.Map; + +public final class WeatherConditionMapper { + + private static final Map 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); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/weather/WeatherController.java b/src/main/java/com/litoralregas/backend/weather/WeatherController.java new file mode 100644 index 0000000..ac41671 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/weather/WeatherController.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/weather/WeatherService.java b/src/main/java/com/litoralregas/backend/weather/WeatherService.java new file mode 100644 index 0000000..0d6f640 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/weather/WeatherService.java @@ -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 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 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.")); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/weather/WeatherSettings.java b/src/main/java/com/litoralregas/backend/weather/WeatherSettings.java new file mode 100644 index 0000000..74c2df7 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/weather/WeatherSettings.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/weather/WeatherSettingsRepository.java b/src/main/java/com/litoralregas/backend/weather/WeatherSettingsRepository.java new file mode 100644 index 0000000..d7d0019 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/weather/WeatherSettingsRepository.java @@ -0,0 +1,7 @@ +package com.litoralregas.backend.weather; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WeatherSettingsRepository + extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/weather/dto/WeatherConditionDto.java b/src/main/java/com/litoralregas/backend/weather/dto/WeatherConditionDto.java new file mode 100644 index 0000000..f97a709 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/weather/dto/WeatherConditionDto.java @@ -0,0 +1,7 @@ +package com.litoralregas.backend.weather.dto; + +public record WeatherConditionDto( + String text, + String icon, + Integer code +) {} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/weather/dto/WeatherConfiguredLocationResponse.java b/src/main/java/com/litoralregas/backend/weather/dto/WeatherConfiguredLocationResponse.java new file mode 100644 index 0000000..10c48e9 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/weather/dto/WeatherConfiguredLocationResponse.java @@ -0,0 +1,8 @@ +package com.litoralregas.backend.weather.dto; + +public record WeatherConfiguredLocationResponse( + boolean enabled, + double latitude, + double longitude, + String locationName +) {} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/weather/dto/WeatherCurrentDto.java b/src/main/java/com/litoralregas/backend/weather/dto/WeatherCurrentDto.java new file mode 100644 index 0000000..1a22ab1 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/weather/dto/WeatherCurrentDto.java @@ -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 +) {} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/weather/dto/WeatherDailyDto.java b/src/main/java/com/litoralregas/backend/weather/dto/WeatherDailyDto.java new file mode 100644 index 0000000..4276e98 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/weather/dto/WeatherDailyDto.java @@ -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 +) {} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/weather/dto/WeatherForecastResponse.java b/src/main/java/com/litoralregas/backend/weather/dto/WeatherForecastResponse.java new file mode 100644 index 0000000..7839156 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/weather/dto/WeatherForecastResponse.java @@ -0,0 +1,9 @@ +package com.litoralregas.backend.weather.dto; + +import java.util.List; + +public record WeatherForecastResponse( + WeatherLocationDto location, + WeatherCurrentDto current, + List daily +) {} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/weather/dto/WeatherLocationDto.java b/src/main/java/com/litoralregas/backend/weather/dto/WeatherLocationDto.java new file mode 100644 index 0000000..0a6ac2c --- /dev/null +++ b/src/main/java/com/litoralregas/backend/weather/dto/WeatherLocationDto.java @@ -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 +) {} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/weather/dto/WeatherLocationUpdateRequest.java b/src/main/java/com/litoralregas/backend/weather/dto/WeatherLocationUpdateRequest.java new file mode 100644 index 0000000..7f2b7a6 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/weather/dto/WeatherLocationUpdateRequest.java @@ -0,0 +1,7 @@ +package com.litoralregas.backend.weather.dto; + +public record WeatherLocationUpdateRequest( + double latitude, + double longitude, + String locationName +) {} \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 9b58391..aa3f4ca 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/src/main/resources/db/migration/V6__create_weather_settings.sql b/src/main/resources/db/migration/V6__create_weather_settings.sql new file mode 100644 index 0000000..d653c97 --- /dev/null +++ b/src/main/resources/db/migration/V6__create_weather_settings.sql @@ -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 +); \ No newline at end of file