diff --git a/src/main/angular/src/app/dashboard/dashboard.component.ts b/src/main/angular/src/app/dashboard/dashboard.component.ts
index dced2b7..53b9fee 100644
--- a/src/main/angular/src/app/dashboard/dashboard.component.ts
+++ b/src/main/angular/src/app/dashboard/dashboard.component.ts
@@ -2,11 +2,13 @@ import {Component} from '@angular/core';
import {PlotService} from '../plot/plot.service';
import {Plot} from '../plot/Plot';
import {PlotComponent} from '../plot/plot/plot.component';
+import {WeatherComponent} from '../weather/plot/weather-component';
@Component({
selector: 'app-dashboard',
imports: [
- PlotComponent
+ PlotComponent,
+ WeatherComponent
],
templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.less'
diff --git a/src/main/angular/src/app/plot/editor/plot-editor.component.ts b/src/main/angular/src/app/plot/editor/plot-editor.component.ts
index da7dd75..0e7f3a5 100644
--- a/src/main/angular/src/app/plot/editor/plot-editor.component.ts
+++ b/src/main/angular/src/app/plot/editor/plot-editor.component.ts
@@ -1,7 +1,6 @@
import {Component, OnDestroy, OnInit} from '@angular/core';
import {BarController, BarElement, CategoryScale, Chart, Filler, Legend, LinearScale, LineController, LineElement, PointElement, TimeScale, Title, Tooltip} from 'chart.js';
import {SeriesService} from "../../series/series.service";
-import 'chartjs-adapter-date-fns';
import {SeriesType} from '../../series/SeriesType';
import {PlotService} from '../plot.service';
import {Plot} from '../Plot';
diff --git a/src/main/angular/src/app/plot/plot/plot.component.ts b/src/main/angular/src/app/plot/plot/plot.component.ts
index 4fd1b8a..787fec3 100644
--- a/src/main/angular/src/app/plot/plot/plot.component.ts
+++ b/src/main/angular/src/app/plot/plot/plot.component.ts
@@ -1,6 +1,5 @@
import {AfterViewInit, Component, ElementRef, Input, OnDestroy, ViewChild} from '@angular/core';
import {BarController, BarElement, CategoryScale, Chart, ChartDataset, Filler, Legend, LinearScale, LineController, LineElement, PointElement, TimeScale, Title, Tooltip} from 'chart.js';
-import 'chartjs-adapter-date-fns';
import {toAvg, toBool, toDelta, toMax, toMin} from '../../series/MinMaxAvg';
import {SeriesType} from '../../series/SeriesType';
import {Plot} from '../Plot';
diff --git a/src/main/angular/src/app/weather/WeatherHour.ts b/src/main/angular/src/app/weather/WeatherHour.ts
new file mode 100644
index 0000000..67e7f87
--- /dev/null
+++ b/src/main/angular/src/app/weather/WeatherHour.ts
@@ -0,0 +1,25 @@
+import {validateDate, validateNumber} from '../COMMON';
+
+export class WeatherHour {
+
+ constructor(
+ readonly date: Date,
+ readonly clouds: number,
+ readonly irradiation: number,
+ readonly precipitation: number,
+ readonly temperature: number,
+ ) {
+ //
+ }
+
+ static fromJson(json: any): WeatherHour {
+ return new WeatherHour(
+ validateDate(json.date),
+ validateNumber(json.clouds),
+ validateNumber(json.irradiation),
+ validateNumber(json.precipitation),
+ validateNumber(json.temperature),
+ );
+ }
+
+}
diff --git a/src/main/angular/src/app/weather/plot/weather-component.html b/src/main/angular/src/app/weather/plot/weather-component.html
new file mode 100644
index 0000000..4585a0f
--- /dev/null
+++ b/src/main/angular/src/app/weather/plot/weather-component.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/main/angular/src/app/weather/plot/weather-component.less b/src/main/angular/src/app/weather/plot/weather-component.less
new file mode 100644
index 0000000..cbe226c
--- /dev/null
+++ b/src/main/angular/src/app/weather/plot/weather-component.less
@@ -0,0 +1,4 @@
+.container {
+ width: 100%;
+ height: 100%;
+}
diff --git a/src/main/angular/src/app/weather/plot/weather-component.ts b/src/main/angular/src/app/weather/plot/weather-component.ts
new file mode 100644
index 0000000..ec8df5e
--- /dev/null
+++ b/src/main/angular/src/app/weather/plot/weather-component.ts
@@ -0,0 +1,204 @@
+import {AfterViewInit, Component, ElementRef, ViewChild} from '@angular/core';
+import {WeatherService} from '../weather-service';
+import {WeatherHour} from '../WeatherHour';
+import {Chart} from 'chart.js';
+
+import {de} from 'date-fns/locale';
+import {format} from 'date-fns';
+
+export function toPoint(f: (hour: WeatherHour) => number): (hour: WeatherHour) => { x: number, y: number } {
+ return (hour: WeatherHour) => {
+ return {
+ x: hour.date.getTime(),
+ y: f(hour),
+ };
+ };
+}
+
+const TEMPERATURE_HIGH_COLOR = '#FF0000';
+const TEMPERATURE_LOW_COLOR = '#0000FF';
+const TEMPERATURE_LOW_THRESHOLD = 0;
+const PRECIPITATION_COLOR = '#0000FF';
+const CLOUDS_COLOR = '#cccccc';
+const SUN_COLOR = '#ffc400';
+
+@Component({
+ selector: 'app-weather-component',
+ imports: [],
+ templateUrl: './weather-component.html',
+ styleUrl: './weather-component.less'
+})
+export class WeatherComponent implements AfterViewInit {
+
+ @ViewChild('chartCanvas')
+ protected canvasRef!: ElementRef
;
+
+ @ViewChild('container')
+ protected chartContainer!: ElementRef;
+
+ private chart!: Chart;
+
+ constructor(
+ readonly weatherService: WeatherService,
+ ) {
+ //
+ }
+
+ ngAfterViewInit(): void {
+ this.chart = new Chart(this.canvasRef.nativeElement, {
+ type: 'line',
+ data: {
+ datasets: [],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ animation: false,
+ plugins: {
+ legend: {
+ display: true,
+ },
+ tooltip: {
+ position: 'nearest',
+ mode: 'index',
+ usePointStyle: true,
+ callbacks: {
+ title: function (items) {
+ const date = items[0].parsed.x as unknown as Date;
+ return format(date, 'EE dd.MM. HH:mm', {locale: de});
+ },
+ },
+ },
+ title: {
+ display: true,
+ text: "Wetter",
+ }
+ },
+ scales: {
+ x: {
+ type: 'time',
+ time: {
+ unit: "day",
+ displayFormats: {
+ day: "EE dd.MM"
+ }
+ },
+ adapters: {
+ date: {
+ locale: de,
+ }
+ },
+ },
+ y_temperature: {
+ position: "right",
+ title: {
+ display: true,
+ text: "Temperatur [°C]",
+ color: TEMPERATURE_HIGH_COLOR,
+ },
+ ticks: {
+ color: TEMPERATURE_HIGH_COLOR,
+ },
+ min: 0,
+ max: 35,
+ },
+ y_precipitation: {
+ title: {
+ display: true,
+ text: "Niederschlag [mm]",
+ color: PRECIPITATION_COLOR,
+ },
+ ticks: {
+ color: PRECIPITATION_COLOR,
+ },
+ min: 0,
+ max: 15,
+ },
+ y_sun: {
+ position: "right",
+ title: {
+ display: true,
+ text: "Sonne [kWh/m²]",
+ color: SUN_COLOR,
+ },
+ ticks: {
+ color: SUN_COLOR,
+ },
+ min: 0,
+ max: 1000,
+ },
+ y_clouds: {
+ display: false,
+ title: {
+ display: true,
+ text: "Wolkenbedeckung [%]",
+ color: CLOUDS_COLOR,
+ },
+ ticks: {
+ color: CLOUDS_COLOR,
+ },
+ min: 0,
+ max: 100,
+ },
+ },
+ },
+ });
+ this.weatherService.all(hours => {
+ const now = Date.now();
+ const filtered = hours.filter(h => h.date.getTime() >= now);
+ this.chart.data.datasets.push({
+ label: "Niederschlag",
+ type: "bar",
+ yAxisID: "y_precipitation",
+ data: filtered.map(toPoint(h => h.precipitation)),
+ borderColor: PRECIPITATION_COLOR,
+ backgroundColor: PRECIPITATION_COLOR + '66',
+ borderWidth: 0,
+ pointStyle: "rect",
+ });
+ this.chart.data.datasets.push({
+ label: "Sonne",
+ type: "line",
+ fill: "origin",
+ yAxisID: "y_sun",
+ data: filtered.map(toPoint(h => h.irradiation)),
+ borderColor: SUN_COLOR,
+ backgroundColor: SUN_COLOR + '88',
+ borderWidth: 0,
+ pointRadius: 0,
+ });
+ this.chart.data.datasets.push({
+ label: "Temperatur",
+ type: "line",
+ yAxisID: "y_temperature",
+ data: filtered.map(toPoint(h => h.temperature)),
+ borderColor: function (context) {
+ const value = context.dataset.data[context.dataIndex];
+ return value !== null && typeof value === 'object' && value.hasOwnProperty("y") && value.y >= TEMPERATURE_LOW_THRESHOLD ? TEMPERATURE_HIGH_COLOR : TEMPERATURE_LOW_COLOR;
+ },
+ backgroundColor: TEMPERATURE_HIGH_COLOR + '66',
+ fill: {
+ target: {value: TEMPERATURE_LOW_THRESHOLD},
+ above: TEMPERATURE_HIGH_COLOR + '66',
+ below: TEMPERATURE_LOW_COLOR + '66',
+ },
+ borderWidth: 0,
+ pointRadius: 5,
+ pointStyle: "cross",
+ });
+ this.chart.data.datasets.push({
+ label: "Wolken",
+ type: "line",
+ fill: "origin",
+ yAxisID: "y_clouds",
+ data: filtered.map(toPoint(h => h.clouds)),
+ borderColor: CLOUDS_COLOR,
+ backgroundColor: CLOUDS_COLOR + '88',
+ borderWidth: 0,
+ pointRadius: 0,
+ });
+ this.chart.update();
+ });
+ }
+
+}
diff --git a/src/main/angular/src/app/weather/weather-service.ts b/src/main/angular/src/app/weather/weather-service.ts
new file mode 100644
index 0000000..be77f96
--- /dev/null
+++ b/src/main/angular/src/app/weather/weather-service.ts
@@ -0,0 +1,20 @@
+import {Injectable} from '@angular/core';
+import {ApiService, CrudService, Next} from '../COMMON';
+import {WeatherHour} from './WeatherHour';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class WeatherService extends CrudService {
+
+ constructor(
+ api: ApiService,
+ ) {
+ super(api, ['Weather'], WeatherHour.fromJson);
+ }
+
+ all(next: Next) {
+ this.getList(['all'], next);
+ }
+
+}
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..9f991e3
--- /dev/null
+++ b/src/main/java/de/ph87/data/weather/BrightSkyDto.java
@@ -0,0 +1,81 @@
+package de.ph87.data.weather;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Data;
+import lombok.Getter;
+import lombok.NonNull;
+import lombok.ToString;
+
+import java.time.ZonedDateTime;
+import java.util.List;
+import java.util.TimeZone;
+
+@Data
+@SuppressWarnings("unused")
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class BrightSkyDto {
+
+ private List weather;
+
+ @Getter
+ @ToString
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class Hour {
+
+ 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());
+ }
+
+ public double getSolar() {
+ return solar == null ? 0.0 : solar;
+ }
+
+ public double getCloud_cover() {
+ return cloud_cover == null ? 0.0 : cloud_cover;
+ }
+
+ public double getPrecipitation() {
+ return precipitation == null ? 0.0 : precipitation;
+ }
+
+ }
+
+}
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..8a05b25
--- /dev/null
+++ b/src/main/java/de/ph87/data/weather/WeatherConfig.java
@@ -0,0 +1,22 @@
+package de.ph87.data.weather;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@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..1b59384
--- /dev/null
+++ b/src/main/java/de/ph87/data/weather/WeatherController.java
@@ -0,0 +1,24 @@
+package de.ph87.data.weather;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.CrossOrigin;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@CrossOrigin
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("Weather")
+public class WeatherController {
+
+ private final WeatherService weatherService;
+
+ @GetMapping("all")
+ public List all() {
+ return weatherService.all();
+ }
+
+}
diff --git a/src/main/java/de/ph87/data/weather/WeatherHour.java b/src/main/java/de/ph87/data/weather/WeatherHour.java
new file mode 100644
index 0000000..4f2d68b
--- /dev/null
+++ b/src/main/java/de/ph87/data/weather/WeatherHour.java
@@ -0,0 +1,36 @@
+package de.ph87.data.weather;
+
+import lombok.Data;
+import lombok.NonNull;
+import lombok.ToString;
+
+import java.time.ZonedDateTime;
+
+@Data
+@ToString(includeFieldNames = false)
+public class WeatherHour {
+
+ @NonNull
+ public final ZonedDateTime date;
+
+ @NonNull
+ public final double clouds;
+
+ @NonNull
+ public final double irradiation;
+
+ @NonNull
+ public final double precipitation;
+
+ @NonNull
+ public final double temperature;
+
+ public WeatherHour(@NonNull final BrightSkyDto.Hour dto) {
+ date = dto.getTimestamp();
+ clouds = dto.getCloud_cover();
+ irradiation = dto.getSolar() * 1000;
+ precipitation = dto.getPrecipitation();
+ temperature = (dto.getTemperature());
+ }
+
+}
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..f42c75c
--- /dev/null
+++ b/src/main/java/de/ph87/data/weather/WeatherService.java
@@ -0,0 +1,77 @@
+package de.ph87.data.weather;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.event.ApplicationStartedEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+
+@Slf4j
+@Service
+@EnableScheduling
+@RequiredArgsConstructor
+public class WeatherService {
+
+ private List hours = new ArrayList<>();
+
+ private final WeatherConfig weatherConfig;
+
+ private final ObjectMapper objectMapper;
+
+ @NonNull
+ public List all() {
+ return new ArrayList<>(hours);
+ }
+
+ @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 hours = new ArrayList<>();
+ log.debug("Updating Weather...");
+ for (LocalDate day = first; !day.isAfter(end); day = day.plusDays(1)) {
+ fetchDay(day).getWeather().stream().map(WeatherHour::new).forEach(hours::add);
+ }
+ this.hours = hours;
+ log.info("Weather update complete");
+ } catch (Exception e) {
+ log.error("Failed fetching Weather data: {}", 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);
+ }
+
+}