From 906be87e5083f697dc73e3f5b079141b9d4927f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20Ha=C3=9Fel?= Date: Fri, 28 Feb 2025 11:32:50 +0100 Subject: [PATCH] Weather --- src/main/angular/src/app/core/api.service.ts | 2 +- src/main/angular/src/app/core/validators.ts | 10 +++ .../app/dashboard/dashboard.component.html | 2 + .../src/app/dashboard/dashboard.component.ts | 4 +- .../percent-bar/percent-bar.component.html | 6 +- src/main/angular/src/app/value/Unit.ts | 8 ++ src/main/angular/src/app/value/Value.ts | 33 ++++++-- .../app/weather/weather-diagram/WeatherDay.ts | 27 +++++++ .../weather/weather-diagram/WeatherHour.ts | 24 ++++++ .../weather-diagram.component.html | 12 +++ .../weather-diagram.component.less | 32 ++++++++ .../weather-diagram.component.ts | 77 ++++++++++++++++++ .../weather-diagram/weather.service.ts | 22 ++++++ src/main/java/de/ph87/data/value/Unit.java | 7 ++ src/main/java/de/ph87/data/value/Value.java | 31 ++++++++ .../de/ph87/data/weather/BrightSkyDto.java | 65 +++++++++++++++ .../de/ph87/data/weather/WeatherConfig.java | 22 ++++++ .../ph87/data/weather/WeatherController.java | 25 ++++++ .../java/de/ph87/data/weather/WeatherDay.java | 79 +++++++++++++++++++ .../de/ph87/data/weather/WeatherService.java | 76 ++++++++++++++++++ 20 files changed, 554 insertions(+), 10 deletions(-) create mode 100644 src/main/angular/src/app/weather/weather-diagram/WeatherDay.ts create mode 100644 src/main/angular/src/app/weather/weather-diagram/WeatherHour.ts create mode 100644 src/main/angular/src/app/weather/weather-diagram/weather-diagram.component.html create mode 100644 src/main/angular/src/app/weather/weather-diagram/weather-diagram.component.less create mode 100644 src/main/angular/src/app/weather/weather-diagram/weather-diagram.component.ts create mode 100644 src/main/angular/src/app/weather/weather-diagram/weather.service.ts create mode 100644 src/main/java/de/ph87/data/weather/BrightSkyDto.java create mode 100644 src/main/java/de/ph87/data/weather/WeatherConfig.java create mode 100644 src/main/java/de/ph87/data/weather/WeatherController.java create mode 100644 src/main/java/de/ph87/data/weather/WeatherDay.java create mode 100644 src/main/java/de/ph87/data/weather/WeatherService.java diff --git a/src/main/angular/src/app/core/api.service.ts b/src/main/angular/src/app/core/api.service.ts index 49ff931..b6d864a 100644 --- a/src/main/angular/src/app/core/api.service.ts +++ b/src/main/angular/src/app/core/api.service.ts @@ -4,7 +4,7 @@ import {map, Subscription} from 'rxjs'; import {StompService} from '@stomp/ng2-stompjs'; import {FromJson, Next} from './types'; -const DEV_TO_PROD = true; +const DEV_TO_PROD = false; @Injectable({ providedIn: 'root' diff --git a/src/main/angular/src/app/core/validators.ts b/src/main/angular/src/app/core/validators.ts index 81168c6..5af8a53 100644 --- a/src/main/angular/src/app/core/validators.ts +++ b/src/main/angular/src/app/core/validators.ts @@ -1,3 +1,5 @@ +import {FromJson} from './types'; + export function validateString(value: any): string { if (typeof value !== 'string') { throw new Error('Not a string: ' + value); @@ -11,3 +13,11 @@ export function validateNumber(value: any): number { } return value as number; } + +export function validateDate(value: any): Date { + return new Date(validateString(value)); +} + +export function validateList(value: any[], fromJson: FromJson): T[] { + return value.map(fromJson); +} diff --git a/src/main/angular/src/app/dashboard/dashboard.component.html b/src/main/angular/src/app/dashboard/dashboard.component.html index 54e3528..40e62f1 100644 --- a/src/main/angular/src/app/dashboard/dashboard.component.html +++ b/src/main/angular/src/app/dashboard/dashboard.component.html @@ -1,3 +1,5 @@ + + diff --git a/src/main/angular/src/app/dashboard/dashboard.component.ts b/src/main/angular/src/app/dashboard/dashboard.component.ts index c449edc..3d7cc90 100644 --- a/src/main/angular/src/app/dashboard/dashboard.component.ts +++ b/src/main/angular/src/app/dashboard/dashboard.component.ts @@ -1,12 +1,14 @@ import { Component } from '@angular/core'; import {ElectroEnergyComponent} from "../electro/energy/electro-energy.component"; import {ElectroPowerComponent} from "../electro/power/electro-power.component"; +import {WeatherDiagramComponent} from '../weather/weather-diagram/weather-diagram.component'; @Component({ selector: 'app-dashboard', imports: [ ElectroEnergyComponent, - ElectroPowerComponent + ElectroPowerComponent, + WeatherDiagramComponent ], templateUrl: './dashboard.component.html', styleUrl: './dashboard.component.less' diff --git a/src/main/angular/src/app/shared/percent-bar/percent-bar.component.html b/src/main/angular/src/app/shared/percent-bar/percent-bar.component.html index 216986a..4fa9c37 100644 --- a/src/main/angular/src/app/shared/percent-bar/percent-bar.component.html +++ b/src/main/angular/src/app/shared/percent-bar/percent-bar.component.html @@ -1,17 +1,17 @@
-
+
{{ _purchase?.formatted }}
{{ purchasePercent?.formatted }}
-
+
{{ _self?.formatted }}
{{ selfPercent?.formatted }}
-
+
{{ _delivery?.formatted }}
{{ deliveryPercent?.formatted }} diff --git a/src/main/angular/src/app/value/Unit.ts b/src/main/angular/src/app/value/Unit.ts index ecca926..90a15f4 100644 --- a/src/main/angular/src/app/value/Unit.ts +++ b/src/main/angular/src/app/value/Unit.ts @@ -12,6 +12,14 @@ export class Unit { static readonly PERCENT = new Unit('PERCENT', "%"); + static readonly CLOUD_COVER_PERCENT = new Unit('CLOUD_COVER_PERCENT', '%'); + + static readonly IRRADIATION_WH_M2 = new Unit('IRRADIATION_WH_M2', 'Wh/m²'); + + static readonly IRRADIATION_KWH_M2 = new Unit('IRRADIATION_KWH_M2', 'kWh/m²'); + + static readonly PRECIPITATION_MM = new Unit('PRECIPITATION_MM', 'mm'); + private constructor( readonly name: string, readonly unit: string, diff --git a/src/main/angular/src/app/value/Value.ts b/src/main/angular/src/app/value/Value.ts index 93a527e..c6e1705 100644 --- a/src/main/angular/src/app/value/Value.ts +++ b/src/main/angular/src/app/value/Value.ts @@ -11,8 +11,22 @@ export class Value { // } + static fromJson2(json: any, locale: string): Value { + return new Value( + validateNumber(json['value']), + Unit.fromJson(json['unit']), + validateNumber(json['decimals']), + locale + ); + } + static fromJson(value: any, unit: Unit, decimals: number, locale: string): Value { - return new Value(validateNumber(value), unit, decimals, locale); + return new Value( + validateNumber(value), + unit, + decimals, + locale + ); } get zero(): boolean { @@ -20,7 +34,15 @@ export class Value { } get formatted(): string { - return `${this.value.toLocaleString(this.locale, {maximumFractionDigits: this.decimals, minimumFractionDigits: this.decimals})} ${this.unit.unit}`; + return `${(this.localeString)} ${this.unit.unit}`; + } + + get formatted2(): string { + return `${(this.localeString)}${this.unit.unit}`; + } + + get localeString(): string { + return this.value.toLocaleString(this.locale, {maximumFractionDigits: this.decimals, minimumFractionDigits: this.decimals}); } negate() { @@ -55,11 +77,12 @@ export class Value { return new Value(0, this.unit, this.decimals, this.locale); } - percent(other: Value | undefined): Value | undefined { - if (!other || other.value === 0) { + percent(other: Value | number | undefined): Value | undefined { + const v = other instanceof Value ? other.value : typeof other === "number" ? other : 0; + if (v === 0) { return undefined; } - return new Value(this.value / other.value * 100, Unit.PERCENT, 0, this.locale); + return new Value(this.value / v * 100, Unit.PERCENT, 0, this.locale); } } diff --git a/src/main/angular/src/app/weather/weather-diagram/WeatherDay.ts b/src/main/angular/src/app/weather/weather-diagram/WeatherDay.ts new file mode 100644 index 0000000..1f61e23 --- /dev/null +++ b/src/main/angular/src/app/weather/weather-diagram/WeatherDay.ts @@ -0,0 +1,27 @@ +import {Value} from "../../value/Value"; +import {validateDate, validateList} from "../../core/validators"; +import {WeatherHour} from "./WeatherHour"; + +export class WeatherDay { + + constructor( + readonly date: Date, + readonly hours: WeatherHour[], + readonly clouds: Value, + readonly irradiation: Value, + readonly precipitation: Value, + ) { + // + } + + static fromJson(json: any, locale: string): WeatherDay { + return new WeatherDay( + validateDate(json['date']), + validateList(json['hours'], hour => WeatherHour.fromJson(hour, locale)), + Value.fromJson2(json['clouds'], locale), + Value.fromJson2(json['irradiation'], locale), + Value.fromJson2(json['precipitation'], locale), + ); + } + +} diff --git a/src/main/angular/src/app/weather/weather-diagram/WeatherHour.ts b/src/main/angular/src/app/weather/weather-diagram/WeatherHour.ts new file mode 100644 index 0000000..874a12f --- /dev/null +++ b/src/main/angular/src/app/weather/weather-diagram/WeatherHour.ts @@ -0,0 +1,24 @@ +import {Value} from '../../value/Value'; +import {validateDate} from '../../core/validators'; + +export class WeatherHour { + + constructor( + readonly date: Date, + readonly clouds: Value, + readonly irradiation: Value, + readonly precipitation: Value, + ) { + // + } + + static fromJson(json: any, locale: string): WeatherHour { + return new WeatherHour( + validateDate(json['date']), + Value.fromJson2(json['clouds'], locale), + Value.fromJson2(json['irradiation'], locale), + Value.fromJson2(json['precipitation'], locale), + ); + } + +} diff --git a/src/main/angular/src/app/weather/weather-diagram/weather-diagram.component.html b/src/main/angular/src/app/weather/weather-diagram/weather-diagram.component.html new file mode 100644 index 0000000..40ccb9a --- /dev/null +++ b/src/main/angular/src/app/weather/weather-diagram/weather-diagram.component.html @@ -0,0 +1,12 @@ +
+
+ Wetter +
+
+
+
+
+
+
+
+
diff --git a/src/main/angular/src/app/weather/weather-diagram/weather-diagram.component.less b/src/main/angular/src/app/weather/weather-diagram/weather-diagram.component.less new file mode 100644 index 0000000..a18ee55 --- /dev/null +++ b/src/main/angular/src/app/weather/weather-diagram/weather-diagram.component.less @@ -0,0 +1,32 @@ +.day { + display: flex; + height: 3em; + background-color: #2d4255; + + .hour { + position: relative; + display: flex; + align-items: flex-end; + width: 100%; // any width will do (flex cares about real with) + + .bar { + position: absolute; + width: 100%; + opacity: 0.5; + } + + .clouds { + background-color: white; + } + + .irradiation { + background-color: yellow; + } + + .precipitation { + background-color: blue; + } + + } + +} diff --git a/src/main/angular/src/app/weather/weather-diagram/weather-diagram.component.ts b/src/main/angular/src/app/weather/weather-diagram/weather-diagram.component.ts new file mode 100644 index 0000000..fd742d0 --- /dev/null +++ b/src/main/angular/src/app/weather/weather-diagram/weather-diagram.component.ts @@ -0,0 +1,77 @@ +import {Component, OnInit} from '@angular/core'; +import {NgForOf} from '@angular/common'; +import {WeatherHour} from './WeatherHour'; +import {WeatherService} from './weather.service'; +import {WeatherDay} from './WeatherDay'; + +const PAST_HOURS_COUNT = 0; + +const DAY_COUNT = 7; + +@Component({ + selector: 'app-weather-diagram', + imports: [ + NgForOf + ], + templateUrl: './weather-diagram.component.html', + styleUrl: './weather-diagram.component.less' +}) +export class WeatherDiagramComponent implements OnInit { + + protected days: WeatherDay[] = []; + + protected hours: WeatherHour[] = []; + + constructor( + readonly weatherService: WeatherService, + ) { + // + } + + ngOnInit(): void { + this.weatherService.all(all => { + this.days = all; + this.updateHours(); + }) + } + + clouds(hour: WeatherHour): string { + return (hour.clouds?.value || 0) + '%'; + } + + irradiation(hour: WeatherHour): string { + return (hour.irradiation.percent(1000)?.value || 0) + '%'; + } + + precipitation(hour: WeatherHour) { + return (hour.precipitation.percent(15)?.value || 0) + '%'; + } + + private updateHours() { + const nowHour = new Date(); + nowHour.setMinutes(0); + nowHour.setSeconds(0); + nowHour.setMilliseconds(0); + + const firstHour = new Date(nowHour); + firstHour.setHours(firstHour.getHours() - PAST_HOURS_COUNT); + + const endHour = new Date(firstHour); + endHour.setHours(endHour.getHours() + 24 * DAY_COUNT); + + this.hours = []; + const currentHour = new Date(firstHour); + for (const day of this.days) { + for (const hour of day.hours) { + if (hour.date.getTime() === currentHour.getTime()) { + this.hours.push(hour); + currentHour.setHours(currentHour.getHours() + 1); + if (currentHour.getTime() >= endHour.getTime()) { + return; + } + } + } + } + } + +} diff --git a/src/main/angular/src/app/weather/weather-diagram/weather.service.ts b/src/main/angular/src/app/weather/weather-diagram/weather.service.ts new file mode 100644 index 0000000..1fca8b7 --- /dev/null +++ b/src/main/angular/src/app/weather/weather-diagram/weather.service.ts @@ -0,0 +1,22 @@ +import {Inject, Injectable, LOCALE_ID} from '@angular/core'; +import {ApiService} from '../../core/api.service'; +import {Next} from '../../core/types'; +import {WeatherDay} from './WeatherDay'; + +@Injectable({ + providedIn: 'root' +}) +export class WeatherService { + + constructor( + readonly api: ApiService, + @Inject(LOCALE_ID) readonly locale: string, + ) { + // + } + + all(next: Next) { + return this.api.getList(['Weather', 'all'], json => WeatherDay.fromJson(json, this.locale), next); + } + +} diff --git a/src/main/java/de/ph87/data/value/Unit.java b/src/main/java/de/ph87/data/value/Unit.java index 2fbee31..aad3fdc 100644 --- a/src/main/java/de/ph87/data/value/Unit.java +++ b/src/main/java/de/ph87/data/value/Unit.java @@ -38,6 +38,13 @@ public enum Unit { SUN_DC("Δ°C"), UNIT_PERCENT("%"), + + CLOUD_COVER_PERCENT("%"), + + IRRADIATION_WH_M2("Wh/m²"), + IRRADIATION_KWH_M2("kWh/m²", 1000, IRRADIATION_WH_M2), + + PRECIPITATION_MM("mm"), ; public final String unit; diff --git a/src/main/java/de/ph87/data/value/Value.java b/src/main/java/de/ph87/data/value/Value.java index 6e173fb..bedbcde 100644 --- a/src/main/java/de/ph87/data/value/Value.java +++ b/src/main/java/de/ph87/data/value/Value.java @@ -2,17 +2,31 @@ package de.ph87.data.value; import lombok.*; +import java.util.*; +import java.util.function.*; + +@Data public class Value { public final double value; public final Unit unit; + public final int decimals; + public Value(final double value, @NonNull final Unit unit) { this.value = value; this.unit = unit; + this.decimals = 1; } + public Value(final double value, @NonNull final Unit unit, final int decimals) { + this.value = value; + this.unit = unit; + this.decimals = decimals; + } + + @NonNull public Value as(@NonNull final Unit target) { if (this.unit == target) { return this; @@ -23,4 +37,21 @@ public class Value { return new Value(value * this.unit.factor / target.factor, target); } + @NonNull + public static Value sum(@NonNull final List hours, @NonNull final Function map, @NonNull final Unit unit, final int decimals) { + final double sum = hours.stream().map(map).map(v -> v.as(unit)).map(Value::getValue).reduce(Double::sum).orElse(0.0); + return new Value(sum, unit, decimals); + } + + @NonNull + public static Value avg(@NonNull final List hours, @NonNull final Function map, @NonNull final Unit unit, final int decimals) { + final double avg = sum(hours, map, unit, decimals).as(unit).value / hours.size(); + return new Value(avg, unit, decimals); + } + + @Override + public String toString() { + return "%%.%df%%s".formatted(decimals).formatted(value, unit.unit); + } + } diff --git a/src/main/java/de/ph87/data/weather/BrightSkyDto.java b/src/main/java/de/ph87/data/weather/BrightSkyDto.java new file mode 100644 index 0000000..90f0238 --- /dev/null +++ b/src/main/java/de/ph87/data/weather/BrightSkyDto.java @@ -0,0 +1,65 @@ +package de.ph87.data.weather; + +import com.fasterxml.jackson.annotation.*; +import lombok.*; + +import java.time.*; +import java.util.*; + +@Data +@SuppressWarnings("unused") +@JsonIgnoreProperties(ignoreUnknown = true) +public class BrightSkyDto { + + private List weather; + + @Getter + @ToString + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Weather { + + private ZonedDateTime timestamp; + + private int source_id; + + private Double precipitation; + + private Double pressure_msl; + + private Double sunshine; + + private Double temperature; + + private Double wind_direction; + + private Double wind_speed; + + private Integer cloud_cover; + + private Double dew_point; + + private Double relative_humidity; + + private Double visibility; + + private Double wind_gust_direction; + + private Double wind_gust_speed; + + private String condition; + + private Double precipitation_probability; + + private Double precipitation_probability_6h; + + private Double solar; + + private String icon; + + public void setTimestamp(@NonNull final ZonedDateTime timestamp) { + this.timestamp = timestamp.withZoneSameInstant(TimeZone.getDefault().toZoneId()); + } + + } + +} diff --git a/src/main/java/de/ph87/data/weather/WeatherConfig.java b/src/main/java/de/ph87/data/weather/WeatherConfig.java new file mode 100644 index 0000000..950516a --- /dev/null +++ b/src/main/java/de/ph87/data/weather/WeatherConfig.java @@ -0,0 +1,22 @@ +package de.ph87.data.weather; + +import lombok.*; +import org.springframework.boot.context.properties.*; +import org.springframework.stereotype.*; + +@Data +@Component +@ConfigurationProperties(prefix = "de.ph87.data.weather") +public class WeatherConfig { + + private String urlPattern = "https://api.brightsky.dev/weather?date={date}&lat={latitude}&lon={longitude}&units=dwd"; + + private double latitude = 49.320789191091194; + + private double longitude = 7.102111982262271; + + private int pastDays = 9; + + private int futureDays = 9; + +} diff --git a/src/main/java/de/ph87/data/weather/WeatherController.java b/src/main/java/de/ph87/data/weather/WeatherController.java new file mode 100644 index 0000000..bc9c8ae --- /dev/null +++ b/src/main/java/de/ph87/data/weather/WeatherController.java @@ -0,0 +1,25 @@ +package de.ph87.data.weather; + +import lombok.*; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("Weather") +public class WeatherController { + + private final WeatherService weatherService; + + @GetMapping("update") + public void update() { + weatherService.update(); + } + + @GetMapping("all") + public List all() { + return weatherService.all(); + } + +} diff --git a/src/main/java/de/ph87/data/weather/WeatherDay.java b/src/main/java/de/ph87/data/weather/WeatherDay.java new file mode 100644 index 0000000..74cf7bf --- /dev/null +++ b/src/main/java/de/ph87/data/weather/WeatherDay.java @@ -0,0 +1,79 @@ +package de.ph87.data.weather; + +import de.ph87.data.value.Value; +import de.ph87.data.value.*; +import lombok.*; + +import java.io.*; +import java.time.*; +import java.util.*; +import java.util.stream.*; + +@Data +@ToString(includeFieldNames = false) +public class WeatherDay { + + @NonNull + public final LocalDate date; + + @NonNull + @ToString.Exclude + public final List hours; + + @NonNull + public final Value clouds; + + @NonNull + public final Value irradiation; + + @NonNull + public final Value precipitation; + + public WeatherDay(@NonNull final BrightSkyDto dto) throws IOException { + if (dto.getWeather().size() != 25) { + throw new IOException("Expected 25 hours. But received: %d:\n %s".formatted(dto.getWeather().size(), dto.getWeather().stream().map(BrightSkyDto.Weather::toString).collect(Collectors.joining("\n ")))); + } + date = dto.getWeather().getFirst().getTimestamp().toLocalDate(); + hours = dto.getWeather().stream().map(Hour::new).filter(h -> h.date.toLocalDate().equals(date)).toList(); + clouds = Value.avg(hours, Hour::getClouds, Unit.CLOUD_COVER_PERCENT, 0); + irradiation = Value.sum(hours, Hour::getIrradiation, Unit.IRRADIATION_KWH_M2, 1); + precipitation = Value.sum(hours, Hour::getPrecipitation, Unit.PRECIPITATION_MM, 0); + validate(); + } + + private void validate() throws IOException { + ZonedDateTime date = ZonedDateTime.of(this.date, LocalTime.MIDNIGHT, ZoneId.systemDefault()); + for (final Hour hour : hours) { + if (hour.date.compareTo(date) != 0) { + throw new IOException("Invalid Hour-Date: expected=%s, actual=%s".formatted(date, hour.date)); + } + date = date.plusHours(1); + } + } + + @Data + @ToString(includeFieldNames = false) + public static class Hour { + + @NonNull + public final ZonedDateTime date; + + @NonNull + public final Value clouds; + + @NonNull + public final Value irradiation; + + @NonNull + public final Value precipitation; + + public Hour(@NonNull final BrightSkyDto.Weather dto) { + date = dto.getTimestamp(); + clouds = new Value(dto.getCloud_cover(), Unit.CLOUD_COVER_PERCENT); + irradiation = new Value(dto.getSolar() * 1000, Unit.IRRADIATION_WH_M2); + precipitation = new Value(dto.getPrecipitation(), Unit.PRECIPITATION_MM); + } + + } + +} diff --git a/src/main/java/de/ph87/data/weather/WeatherService.java b/src/main/java/de/ph87/data/weather/WeatherService.java new file mode 100644 index 0000000..b283bcc --- /dev/null +++ b/src/main/java/de/ph87/data/weather/WeatherService.java @@ -0,0 +1,76 @@ +package de.ph87.data.weather; + +import com.fasterxml.jackson.databind.*; +import lombok.*; +import lombok.extern.slf4j.*; +import org.springframework.boot.context.event.*; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.*; +import org.springframework.stereotype.*; + +import java.io.*; +import java.net.*; +import java.nio.charset.*; +import java.time.*; +import java.time.format.*; +import java.util.*; + +@Slf4j +@Service +@EnableScheduling +@RequiredArgsConstructor +public class WeatherService { + + private List days = new ArrayList<>(); + + private final WeatherConfig weatherConfig; + + private final ObjectMapper objectMapper; + + @NonNull + public List all() { + return new ArrayList<>(days); + } + + @Scheduled(cron = "0 0 * * * *") + @EventListener(ApplicationStartedEvent.class) + public void update() { + try { + final LocalDate today = LocalDate.now(); + final LocalDate first = today.minusDays(weatherConfig.getPastDays()); + final LocalDate end = today.plusDays(weatherConfig.getFutureDays()); + final List newDays = new ArrayList<>(); + log.debug("Updating Weather..."); + for (LocalDate day = first; !day.isAfter(end); day = day.plusDays(1)) { + final WeatherDay weatherDay = new WeatherDay(fetchDay(day)); + newDays.add(weatherDay); + if (log.isDebugEnabled()) { + log.debug(" {}:", weatherDay); + for (WeatherDay.Hour hour : weatherDay.getHours()) { + log.debug(" %s: %4s clouds, %9s, %4s".formatted(hour.date.toLocalTime(), hour.getClouds(), hour.getIrradiation(), hour.getPrecipitation())); + } + } + } + days = newDays; + log.info("Weather update complete"); + } catch (IOException e) { + log.error(e.toString()); + } + } + + @NonNull + public BrightSkyDto fetchDay(@NonNull final LocalDate day) throws IOException { + final String url = weatherConfig.getUrlPattern() + .replace("{date}", ZonedDateTime.of(day, LocalTime.MIDNIGHT, ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)) + .replace("{latitude}", weatherConfig.getLatitude() + "") + .replace("{longitude}", weatherConfig.getLongitude() + ""); + final HttpURLConnection connection = (HttpURLConnection) URI.create(url).toURL().openConnection(); + final int responseCode = connection.getResponseCode(); + final byte[] bytes = connection.getInputStream().readAllBytes(); + if (responseCode / 100 != 2) { + throw new IOException("responseCode=%d, message: %s".formatted(responseCode, new String(bytes, StandardCharsets.UTF_8))); + } + return objectMapper.readValue(bytes, BrightSkyDto.class); + } + +}