angular ui + websocket

This commit is contained in:
Patrick Haßel 2024-10-17 14:04:08 +02:00
parent 297e4b4029
commit 21956c0d28
59 changed files with 16144 additions and 120 deletions

68
pom.xml
View File

@ -35,16 +35,6 @@
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-java8time</artifactId>
<version>3.0.4.RELEASE</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
@ -75,11 +65,45 @@
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.0.0</version>
<executions>
<execution>
<id>npm-install</id>
<phase>generate-sources</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<workingDirectory>${project.basedir}/src/main/angular</workingDirectory>
<executable>npm</executable>
<arguments>
<argument>install</argument>
</arguments>
</configuration>
</execution>
<execution>
<id>ng-build</id>
<phase>generate-sources</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<workingDirectory>${project.basedir}/src/main/angular</workingDirectory>
<executable>ng</executable>
<arguments>
<argument>build</argument>
<argument>--base-href</argument>
<argument>/Data/</argument>
</arguments>
</configuration>
</execution>
<execution>
<id>scp</id>
<phase>package</phase>
@ -96,8 +120,32 @@
</arguments>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>copy-resources</id>
<phase>process-resources</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>target/classes/resources/</outputDirectory>
<resources>
<resource>
<directory>src/main/angular/dist/angular/browser/</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

View File

@ -0,0 +1,13 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
src/main/angular/.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

View File

@ -0,0 +1,27 @@
# Angular
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.2.3.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@ -0,0 +1,130 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"angular": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "less",
"skipTests": true
},
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/angular",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "less",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.less"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kB",
"maximumError": "4kB"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "angular:build:production"
},
"development": {
"buildTarget": "angular:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "less",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.less"
],
"scripts": []
}
}
}
}
}
}

14611
src/main/angular/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
{
"name": "angular",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^18.2.0",
"@angular/common": "^18.2.0",
"@angular/compiler": "^18.2.0",
"@angular/core": "^18.2.0",
"@angular/forms": "^18.2.0",
"@angular/platform-browser": "^18.2.0",
"@angular/platform-browser-dynamic": "^18.2.0",
"@angular/router": "^18.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.10",
"@stomp/ng2-stompjs": "^8.0.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^18.2.3",
"@angular/cli": "^18.2.3",
"@angular/compiler-cli": "^18.2.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.2.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.5.2"
}
}

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="800px" height="800px" viewBox="0 0 64 64">
<path fill="#394240" d="M60,0H4C1.789,0,0,1.789,0,4v56c0,2.211,1.789,4,4,4h56c2.211,0,4-1.789,4-4V4C64,1.789,62.211,0,60,0z
M4,2h56c1.104,0,2,0.896,2,2v21.428l-11.409,6.713C49.49,30.832,47.844,30,46,30c-1.8,0-3.41,0.796-4.51,2.051l-15.93-9.803
C25.842,21.553,26,20.796,26,20c0-3.314-2.686-6-6-6s-6,2.686-6,6c0,1.296,0.414,2.492,1.112,3.473L2,36.586V4C2,2.896,2.896,2,4,2
z M46,32c2.209,0,4,1.791,4,4s-1.791,4-4,4s-4-1.791-4-4S43.791,32,46,32z M16,20c0-2.209,1.791-4,4-4s4,1.791,4,4s-1.791,4-4,4
S16,22.209,16,20z M60,62H4c-1.104,0-2-0.896-2-2V39.414l14.526-14.527C17.507,25.586,18.704,26,20,26c1.8,0,3.41-0.795,4.51-2.051
l15.93,9.804C40.158,34.447,40,35.205,40,36c0,3.314,2.686,6,6,6s6-2.686,6-6c0-0.752-0.145-1.471-0.397-2.135L62,27.748V60
C62,61.104,61.104,62,60,62z"/>
<circle fill="#F76D57" cx="46" cy="36" r="4"/>
<circle fill="#45AAB8" cx="20" cy="20" r="4"/>
<g>
<path fill="#F9EBB2" d="M60,2H4C2.896,2,2,2.896,2,4v32.586l13.112-13.113C14.414,22.492,14,21.296,14,20c0-3.314,2.686-6,6-6
s6,2.686,6,6c0,0.796-0.158,1.553-0.439,2.248l15.93,9.803C42.59,30.796,44.2,30,46,30c1.844,0,3.49,0.832,4.591,2.141L62,25.428
V4C62,2.896,61.104,2,60,2z"/>
</g>
<path fill="#B4CCB9" d="M52,36c0,3.314-2.686,6-6,6s-6-2.686-6-6c0-0.795,0.158-1.553,0.439-2.247l-15.93-9.804
C23.41,25.205,21.8,26,20,26c-1.296,0-2.493-0.414-3.474-1.113L2,39.414V60c0,1.104,0.896,2,2,2h56c1.104,0,2-0.896,2-2V27.748
l-10.397,6.117C51.855,34.529,52,35.248,52,36z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,13 @@
import {CrudDirection} from "./crud/CrudDirection";
export class Order<T> {
constructor(
readonly property: string,
readonly compare: (a: T, b: T) => number,
public direction: CrudDirection,
) {
// -
}
}

View File

@ -0,0 +1,28 @@
import {validateList, validateNumber} from "./validators";
import {FromJson} from "./types";
export class Page<T> {
static readonly EMPTY: Page<any> = new Page(0, 0, 0, 0, []);
constructor(
readonly size: number,
readonly number: number,
readonly totalPages: number,
readonly totalElements: number,
readonly content: T[],
) {
// -
}
static fromJson<T>(fromJson: FromJson<T>): FromJson<Page<T>> {
return (json: any) => new Page<T>(
validateNumber(json.page.size),
validateNumber(json.page.number),
validateNumber(json.page.totalPages),
validateNumber(json.page.totalElements),
validateList(json.content, fromJson),
);
}
}

View File

@ -0,0 +1,65 @@
import {CrudDirection} from "./crud/CrudDirection";
import {Order} from "./Order";
export class Sort<T> {
private list: Order<T>[] = [];
toggle(key: string, compare: (a: T, b: T) => number) {
const index: number = this.indexOf(key);
if (index >= 0) {
const existing: Order<T> = this.list[index];
if (existing.direction === CrudDirection.ASC) {
existing.direction = CrudDirection.DESC;
} else {
this.list.splice(index, 1);
}
} else {
this.list.push(new Order(key, compare, CrudDirection.ASC));
}
}
compare(a: T, b: T): number {
for (const order of this.list) {
const result = order.compare(a, b);
if (result != 0) {
return order.direction === CrudDirection.DESC ? -result : result;
}
}
return 0;
}
isEmpty(): boolean {
return this.list.length === 0;
}
indexOf(key: string): number {
return this.list.findIndex(o => o.property === key);
}
isActive(key: string): boolean {
return this.indexOf(key) >= 0;
}
isAscending(key: string): boolean {
return this.isDirection(key, CrudDirection.ASC);
}
isDescending(key: string): boolean {
return this.isDirection(key, CrudDirection.DESC);
}
isDirection(key: string, direction: CrudDirection): boolean {
const index: number = this.indexOf(key);
if (index < 0) {
return false;
}
return this.list[index].direction === direction;
}
clear(): void {
this.list = [];
}
}

View File

@ -0,0 +1,75 @@
import {Injectable} from '@angular/core';
import {HttpClient} from "@angular/common/http";
import {map, Subscription} from "rxjs";
import {FromJson, getUrl, Next} from "./types";
import {Page} from "./Page";
import {StompService} from "@stomp/ng2-stompjs";
@Injectable({
providedIn: 'root',
})
export class ApiService {
private _websocketError: boolean = false;
get websocketError(): boolean {
return this._websocketError;
}
constructor(
private readonly http: HttpClient,
private readonly stompService: StompService,
) {
stompService.connected$.subscribe(() => this._websocketError = false);
stompService.webSocketErrors$.subscribe(() => this._websocketError = true);
}
connected(next: Next<void>): Subscription {
return this.stompService.connected$.subscribe(_ => next());
}
subscribe<T>(topic: any[], fromJson: FromJson<T>, next: Next<T>): Subscription {
console.info("WEBSOCKET SUBSCRIBE", topic)
return this.stompService
.subscribe(topic.join("/"))
.pipe(
map(message => message.body),
map(b => JSON.parse(b)),
map(j => fromJson(j)),
)
.subscribe(next);
}
getNone<T>(path: any[], next: Next<void> | undefined = undefined): Subscription {
return this.http.get<void>(getUrl('http', path)).subscribe(next);
}
getString(path: any[], next: Next<string>): Subscription {
return this.http.get(getUrl('http', path), {responseType: "text"}).subscribe(next);
}
getSingle<T>(path: any[], fromJson: FromJson<T>, next: Next<T> | undefined = undefined): Subscription {
return this.http.get<any>(getUrl('http', path)).pipe(map(fromJson)).subscribe(next);
}
getList<T>(path: any[], fromJson: FromJson<T>, next: Next<T[]> | undefined = undefined): Subscription {
return this.http.get<any[]>(getUrl('http', path)).pipe(map(list => list.map(fromJson))).subscribe(next);
}
postNone(path: any[], data: any, next: Next<void> | undefined = undefined): Subscription {
return this.http.post<void>(getUrl('http', path), data).subscribe(next);
}
postSingle<T>(path: any[], data: any, fromJson: FromJson<T>, next: Next<T> | undefined = undefined): Subscription {
return this.http.post<any>(getUrl('http', path), data).pipe(map(fromJson)).subscribe(next);
}
postPage<T>(path: any[], data: any, fromJson: FromJson<T>, next: Next<Page<T>> | undefined = undefined): Subscription {
return this.http.post<any>(getUrl('http', path), data).pipe(map(Page.fromJson(fromJson))).subscribe(next);
}
postList<T>(path: any[], data: any, fromJson: FromJson<T>, next: Next<T[]> | undefined = undefined): Subscription {
return this.http.post<any[]>(getUrl('http', path), data).pipe(map(list => list.map(fromJson))).subscribe(next);
}
}

View File

@ -0,0 +1,21 @@
export class Display {
constructor(
readonly title: string,
readonly color: string,
readonly iValue: IValue | null,
) {
// -
}
}
export interface IValue {
readonly date: Date | null;
readonly value: number | null;
readonly unit: string | null;
}

View File

@ -0,0 +1,40 @@
import {Period} from "./consumption/period/Period";
import {validateDate, validateNumber, validateString} from "../validators";
import {IValue} from "./IValue";
export class Series implements IValue {
constructor(
readonly id: number,
readonly name: string,
readonly mode: string,
readonly unit: string,
readonly period: Period | null,
readonly lastDate: Date,
readonly lastValue: number,
) {
// -
}
get date(): Date {
return this.lastDate;
}
get value(): number {
return this.lastValue;
}
static fromJson(json: any): Series {
return new Series(
validateNumber(json['id']),
validateString(json['name']),
validateString(json['mode']),
validateString(json['unit']),
Period.fromJsonOrNull(json['period']),
validateDate(json['lastDate']),
validateNumber(json['lastValue']),
);
}
}

View File

@ -0,0 +1,68 @@
import {IValue} from "./IValue";
type GetValue = (v: number) => number;
type CombineValue = (a: number, b: number) => number;
function dateMin(a: Date, b: Date): Date {
if (a.getTime() < b.getTime()) {
return a;
}
return b;
}
export class Value implements IValue {
constructor(
readonly date: Date | null,
readonly value: number | null,
readonly unit: string | null,
) {
// -
}
static positiveOnly(iValue: IValue | null): Value | null {
return this.map(iValue, v => v > 0 ? v : 0);
}
static negativeOnlyToPositive(iValue: IValue | null): Value | null {
return this.map(iValue, v => v < 0 ? -v : 0);
}
static map(iValue: IValue | null, mapping: GetValue): Value | null {
if (!iValue || !iValue.value) {
return null;
}
const value = mapping(iValue.value);
return new Value(iValue.date, value, iValue.unit);
}
static plus(a: IValue | null, b: IValue | null): Value | null {
return this.bi(a, b, (a, b) => a + b);
}
static minus(a: IValue | null, b: IValue | null): Value | null {
return this.bi(a, b, (a, b) => a - b);
}
static mul(a: IValue | null, b: IValue | null): Value | null {
return this.bi(a, b, (a, b) => a * b);
}
static div(a: IValue | null, b: IValue | null): Value | null {
return this.bi(a, b, (a, b) => a / b);
}
static bi(a: IValue | null, b: IValue | null, combineValue: CombineValue) {
if (!a) {
return new Value(null, null, b?.unit || null);
}
if (!b) {
return new Value(null, null, a?.unit || null);
}
const oldestDate = !a.date || !b.date ? null : dateMin(a.date, b.date);
const difference = !a.value || !b.value ? null : combineValue(a.value, b.value);
return new Value(oldestDate, difference, a.unit);
}
}

View File

@ -0,0 +1,39 @@
import {validateDate, validateNumber, validateString} from "../../../validators";
export class Period {
readonly deltaMillis: number;
readonly deltaValue: number;
constructor(
readonly id: number,
readonly name: string,
readonly firstDate: Date,
readonly firstValue: number,
readonly lastDate: Date,
readonly lastValue: number,
) {
this.deltaMillis = lastDate.getTime() - firstDate.getTime();
this.deltaValue = lastValue - firstValue;
}
static fromJson(json: any): Period {
return new Period(
validateNumber(json['id']),
validateString(json['name']),
validateDate(json['firstDate']),
validateNumber(json['firstValue']),
validateDate(json['lastDate']),
validateNumber(json['lastValue']),
);
}
static fromJsonOrNull(json: any): Period | null {
if (!json) {
return null;
}
return this.fromJson(json);
}
}

View File

@ -0,0 +1,26 @@
import {Injectable} from '@angular/core';
import {Series} from "./Series";
import {ApiService} from "../api.service";
type Next<T> = (t: T) => any;
@Injectable({
providedIn: 'root'
})
export class SeriesService {
constructor(
private readonly api: ApiService,
) {
// -
}
subscribe(next: Next<Series>) {
this.api.subscribe(['Series'], Series.fromJson, next);
}
findAll(next: Next<Series[]>): void {
this.api.getList(['Series', 'findAll'], Series.fromJson, next);
}
}

View File

@ -0,0 +1,9 @@
import {environment} from '../../environments/environment';
export type FromJson<T> = (json: any) => T;
export type Next<T> = (item: T) => void;
export function getUrl(protocol: string, path: any[]): string {
return protocol + (environment.secure ? 's' : '') + '://' + environment.host + ':' + environment.port + '/' + environment.base + path.join('/');
}

View File

@ -0,0 +1,69 @@
import {FromJson} from "./types";
export function validateNumber(json: any): number {
if (!(typeof json == "number")) {
throw new Error("Not a number: " + json + " (" + typeof json + "): " + JSON.stringify(json));
}
return json;
}
export function validateNumberOrNull(json: any): number | null {
if (json == null) {
return null;
}
return validateNumber(json);
}
export function validateBoolean(json: any): boolean {
if (!(typeof json == "boolean")) {
throw new Error("Not a boolean: " + json + " (" + typeof json + "): " + JSON.stringify(json));
}
return json;
}
export function validateBooleanOrNull(json: any): boolean | null {
if (json == null) {
return null;
}
return validateBoolean(json);
}
export function validateString(json: any): string {
if (!(typeof json == "string")) {
throw new Error("Not a string: " + json + " (" + typeof json + "): " + JSON.stringify(json));
}
return json;
}
export function validateStringOrNull(json: any): string | null {
if (json == null) {
return null;
}
return validateString(json);
}
export function validateDate(json: any): Date {
return new Date(Date.parse(validateString(json)));
}
export function validateDateOrNull(json: any): Date | null {
if (json == null) {
return null;
}
return validateDate(json);
}
export function validateList<T>(json: any, fromJson: FromJson<T>): T[] {
return json.map(fromJson);
}
export function validateLocalDateOrNull(json: any): Date | null {
if (json == null) {
return null;
}
return validateLocalDate(json);
}
export function validateLocalDate(json: any): Date {
return new Date(validateString(json));
}

View File

@ -0,0 +1,17 @@
import {StompService} from "@stomp/ng2-stompjs";
import {getUrl} from "./types";
export function stompServiceFactory() {
const stomp = new StompService({
url: getUrl('ws', ['ws']),
debug: false,
heartbeat_in: 2000,
heartbeat_out: 2000,
reconnect_delay: 2000,
headers: {},
});
stomp.connected$.subscribe(_ => console.info("WEBSOCKET CONNECTED"));
stomp.webSocketErrors$.subscribe(_ => console.info("WEBSOCKET DISCONNECTED"));
stomp.activate();
return stomp;
}

View File

@ -0,0 +1 @@
<router-outlet/>

View File

@ -0,0 +1 @@
@import "../common";

View File

@ -0,0 +1,13 @@
import {Component} from '@angular/core';
import {RouterOutlet} from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
templateUrl: './app.component.html',
styleUrl: './app.component.less'
})
export class AppComponent {
}

View File

@ -0,0 +1,23 @@
import {ApplicationConfig, LOCALE_ID, provideZoneChangeDetection} from '@angular/core';
import {provideRouter} from '@angular/router';
import {routes} from './app.routes';
import {provideHttpClient} from "@angular/common/http";
import {stompServiceFactory} from "./api/ws";
import {StompService} from "@stomp/ng2-stompjs";
import {registerLocaleData} from "@angular/common";
import localeDe from '@angular/common/locales/de';
import localeDeExtra from '@angular/common/locales/extra/de';
registerLocaleData(localeDe, 'de-DE', localeDeExtra);
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({eventCoalescing: true}),
provideRouter(routes),
provideHttpClient(),
{provide: LOCALE_ID, useValue: 'de-DE'},
{provide: StompService, useFactory: stompServiceFactory},
],
};

View File

@ -0,0 +1,7 @@
import {Routes} from '@angular/router';
import {DashboardComponent} from "./pages/dashboard/dashboard.component";
export const routes: Routes = [
{path: '', component: DashboardComponent},
{path: '**', redirectTo: '/'},
];

View File

@ -0,0 +1,3 @@
<div class="width100">
<app-values-tile title="Elektrizität" [seriesList]="getElectricitySeries()"></app-values-tile>
</div>

View File

@ -0,0 +1 @@
@import "../../../common";

View File

@ -0,0 +1,92 @@
import {Component, OnInit} from '@angular/core';
import {JsonPipe, NgForOf} from "@angular/common";
import {Series} from "../../api/series/Series";
import {SeriesService} from "../../api/series/series.service";
import {ValuesTileComponent} from "../../shared/values-tile/values-tile.component";
import {Value} from "../../api/series/Value";
import {Display} from "../../api/series/IValue";
const PURCHASING_MUCH = 200;
const PRODUCED_UNTIL_METER_CHANGE = 287.995;
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [
NgForOf,
ValuesTileComponent,
JsonPipe
],
templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.less'
})
export class DashboardComponent implements OnInit {
protected gridPurchased: Series | null = null;
protected gridDelivered: Series | null = null;
protected photovoltaicProduced: Series | null = null;
protected photovoltaicPower: Series | null = null;
protected gridPower: Series | null = null;
constructor(
readonly seriesService: SeriesService,
) {
// -
}
ngOnInit(): void {
this.seriesService.findAll(list => list.forEach(s => this.seriesUpdate(s)));
this.seriesService.subscribe(series => this.seriesUpdate(series));
}
private seriesUpdate(series: Series) {
this.gridPurchased = this.seriesUpdate2(series, this.gridPurchased, 'electricity.grid.purchase.energy');
this.gridDelivered = this.seriesUpdate2(series, this.gridDelivered, 'electricity.grid.delivery.energy');
this.gridPower = this.seriesUpdate2(series, this.gridPower, 'electricity.grid.power');
this.photovoltaicProduced = this.seriesUpdate2(series, this.photovoltaicProduced, 'electricity.photovoltaic.energy');
this.photovoltaicPower = this.seriesUpdate2(series, this.photovoltaicPower, 'electricity.photovoltaic.power');
}
private seriesUpdate2(fresh: Series, old: Series | null, name: string): Series | null {
if (fresh.name !== name) {
return old;
}
if (old == null || old.lastDate.getTime() <= fresh.lastDate.getTime()) {
return fresh;
}
return old;
}
getElectricitySeries(): Display[] {
const consumptionPower = Value.positiveOnly(Value.plus(this.photovoltaicPower, this.gridPower));
const selfConsumed = Value.map(this.photovoltaicProduced, producedTotal => {
const producedAfterChange = producedTotal - PRODUCED_UNTIL_METER_CHANGE;
const deliveredAfterChange = this.gridDelivered?.value || 0;
const selfAfterChange = producedAfterChange - deliveredAfterChange;
const selfRatio = selfAfterChange / producedAfterChange;
return selfRatio * producedTotal;
});
const purchasingAny = (this.gridPower?.value || 0) > 0;
const purchasingMuch = (this.gridPower?.value || 0) > PURCHASING_MUCH;
const color = purchasingMuch ? 'red' : (purchasingAny ? 'orange' : 'green');
return [
new Display('Bezug', '', this.gridPurchased),
new Display('Produktion', '', this.photovoltaicProduced),
new Display('Einspeisung', '', this.gridDelivered),
new Display('Eigenverbrauch', '', selfConsumed),
new Display('Produktion', color, this.photovoltaicPower),
new Display('Netz', color, this.gridPower),
new Display('Verbrauch', color, consumptionPower),
];
}
}

View File

@ -0,0 +1,11 @@
<div class="meter" [class.invalid]="!valid">
<div class="title">{{ title }}</div>
<div class="number">{{ number() }}</div>
<table class="values">
<tr class="rate" *ngFor="let display of seriesList" [style.color]="display.color">
<th>{{ display.title }}</th>
<td class="v">{{ display?.iValue?.value | number:'0.0-0' }}</td>
<td class="u">{{ display?.iValue?.unit }}</td>
</tr>
</table>
</div>

View File

@ -0,0 +1,38 @@
@import "../../../common";
.meter {
position: relative;
padding: @space;
border: 1px solid #ddd;
border-radius: @space;
}
.title {
font-weight: bold;
text-align: center;
}
.number {
font-style: italic;
text-align: center;
font-size: 80%;
}
.values {
width: 100%;
th {
text-align: left;
width: 0;
}
.v {
text-align: right;
}
.u {
text-align: left;
width: 0;
}
}

View File

@ -0,0 +1,104 @@
import {Component, Input, OnDestroy, OnInit} from '@angular/core';
import {Series} from "../../api/series/Series";
import {DecimalPipe, NgForOf, NgIf} from "@angular/common";
import {Subscription, timer} from "rxjs";
import {Display} from "../../api/series/IValue";
export const TOO_OLD_MILLIS = 10000;
@Component({
selector: 'app-values-tile',
standalone: true,
imports: [
NgIf,
DecimalPipe,
NgForOf
],
templateUrl: './values-tile.component.html',
styleUrl: './values-tile.component.less'
})
export class ValuesTileComponent implements OnInit, OnDestroy {
private now: Date = new Date();
private timer?: Subscription;
protected valid: boolean = false;
@Input()
title: string = '';
@Input()
protected seriesList_: Display[] = [];
@Input()
set seriesList(seriesList: Display[]) {
this.seriesList_ = seriesList;
this.displayUpdate();
}
get seriesList(): Display[] {
return this.seriesList_;
}
@Input()
set purchase(purchase: Series | null) {
this.purchase_ = purchase;
this.displayUpdate();
}
get purchase(): Series | null {
return this.purchase_;
}
private purchase_: Series | null = null;
@Input()
set delivery(delivery: Series | null) {
this.delivery_ = delivery;
this.displayUpdate();
}
get delivery(): Series | null {
return this.delivery_;
}
private delivery_: Series | null = null;
@Input()
set rate(rate: Series | null) {
this.rate_ = rate;
this.displayUpdate();
}
get rate(): Series | null {
return this.rate_;
}
private rate_: Series | null = null;
number(): string {
if (this.purchase?.period) {
return this.purchase?.period.name;
} else if (this.delivery?.period) {
return this.delivery?.period.name;
} else if (this.rate?.period) {
return this.rate?.period.name;
}
return '';
}
ngOnInit(): void {
this.timer = timer(0, 1000).subscribe(() => this.displayUpdate());
}
ngOnDestroy(): void {
this.timer?.unsubscribe();
}
private displayUpdate() {
this.now = new Date();
this.valid = this.seriesList.some(d => !!d && !!d.iValue && !!d.iValue.date && (this.now.getTime() - d.iValue.date.getTime()) <= TOO_OLD_MILLIS)
}
}

View File

@ -0,0 +1,2 @@
@import "states";
@import "widths";

View File

@ -0,0 +1,4 @@
@font: 5vw;
@fontDesktop: 18px;
@space: 0.5em;

View File

@ -0,0 +1,7 @@
export const environment = {
production: true,
host: window.location.host.split(":")[0],
port: window.location.port,
base: 'Data/',
secure: window.location.protocol === "https:",
};

View File

@ -0,0 +1,8 @@
export const environment = {
production: false,
host: window.location.host.split(":")[0],
port: 8080,
base: '',
secure: window.location.protocol === "https:",
};

View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Data</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!--suppress HtmlUnknownTarget -->
<link rel="icon" type="image/svg" href="favicon.svg">
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@ -0,0 +1,6 @@
import {bootstrapApplication} from '@angular/platform-browser';
import {appConfig} from './app/app.config';
import {AppComponent} from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));

View File

@ -0,0 +1,6 @@
@import "config";
.invalid, .invalid * {
color: lightgray;
text-decoration: line-through;
}

View File

@ -0,0 +1,21 @@
@import "config";
body {
font-family: sans-serif;
font-size: @font;
margin: 0;
overflow: hidden;
}
div {
overflow: hidden;
box-sizing: border-box;
}
@media (min-width: 1000px) {
body {
font-size: @fontDesktop;
}
}

View File

@ -0,0 +1,29 @@
@import "config";
.width100 {
float: left;
width: 100%;
padding: @space;
}
.width50 {
float: left;
width: 50%;
padding: @space;
}
.width50:nth-child(2n) {
padding-left: calc(@space / 2);
}
.width50:nth-child(2n+1) {
padding-right: calc(@space / 2);
}
@media (min-width: 1000px) {
.width100 {
width: 20em;
}
}

View File

@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

View File

@ -0,0 +1,33 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": [
"ES2022",
"dom"
]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@ -2,8 +2,8 @@ package de.ph87.data.electricity;
public class ElectricityHelpers {
public static double energyToKWh(final double energy, final String energyUnit) {
return switch (energyUnit) {
public static double energyToKWh(final double energy, final String isUnit) {
return switch (isUnit) {
case "mWh" -> energy / 1000000;
case "Wh" -> energy / 1000;
case "kWh" -> energy;
@ -14,8 +14,8 @@ public class ElectricityHelpers {
}
@SuppressWarnings("unused")
public static double powerToW(final double power, final String powerUnit) {
return switch (powerUnit) {
public static double powerToW(final double power, final String isUnit) {
return switch (isUnit) {
case "mW" -> power / 1000;
case "W" -> power;
case "kW" -> power * 1000;

View File

@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.ph87.data.mqtt.MqttEvent;
import de.ph87.data.series.consumption.ConsumptionEvent;
import de.ph87.data.series.measure.MeasureEvent;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -12,15 +13,18 @@ import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import static de.ph87.data.electricity.ElectricityHelpers.energyToKWh;
import static de.ph87.data.electricity.ElectricityHelpers.powerToW;
@Slf4j
@Service
@RequiredArgsConstructor
public class GridReceiver {
public static final String GRID_PURCHASE_SERIES_NAME = "electricity.grid.purchase";
public static final String GRID_PURCHASE = "electricity.grid.purchase.energy";
public static final String GRID_DELIVERY_SERIES_NAME = "electricity.grid.delivery";
public static final String GRID_DELIVERY = "electricity.grid.delivery.energy";
public static final String GRID_POWER = "electricity.grid.power";
private final ApplicationEventPublisher applicationEventPublisher;
@ -40,11 +44,14 @@ public class GridReceiver {
return;
}
final double purchaseKWh = energyToKWh(inbound.purchaseWh, "Wh");
applicationEventPublisher.publishEvent(new ConsumptionEvent(GRID_PURCHASE_SERIES_NAME, "kWh", true, inbound.meter, inbound.date, purchaseKWh));
final double purchase = energyToKWh(inbound.purchaseWh, "Wh");
applicationEventPublisher.publishEvent(new ConsumptionEvent(GRID_PURCHASE, "kWh", true, inbound.meter, inbound.date, purchase));
final double deliveryKWh = energyToKWh(inbound.deliveryWh, "Wh");
applicationEventPublisher.publishEvent(new ConsumptionEvent(GRID_DELIVERY_SERIES_NAME, "kWh", true, inbound.meter, inbound.date, deliveryKWh));
final double delivery = energyToKWh(inbound.deliveryWh, "Wh");
applicationEventPublisher.publishEvent(new ConsumptionEvent(GRID_DELIVERY, "kWh", true, inbound.meter, inbound.date, delivery));
final double power = powerToW(inbound.powerW, "W");
applicationEventPublisher.publishEvent(new MeasureEvent(GRID_POWER, "W", inbound.date, power));
}
}

View File

@ -2,6 +2,7 @@ package de.ph87.data.electricity.photovoltaic;
import de.ph87.data.mqtt.MqttEvent;
import de.ph87.data.series.consumption.ConsumptionEvent;
import de.ph87.data.series.measure.MeasureEvent;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -15,15 +16,18 @@ import java.util.regex.Pattern;
import static de.ph87.data.common.DateTimeHelpers.ZDT;
import static de.ph87.data.electricity.ElectricityHelpers.energyToKWh;
import static de.ph87.data.electricity.ElectricityHelpers.powerToW;
@Slf4j
@Service
@RequiredArgsConstructor
public class PhotovoltaicReceiver {
public static final String PHOTOVOLTAIC_ENERGY_SERIES_NAME = "electricity.photovoltaic.energy";
public static final String PHOTOVOLTAIC_ENERGY = "electricity.photovoltaic.energy";
private static final Pattern REGEX = Pattern.compile("^(?<serial>\\S+) (?<epochSeconds>\\d+) (?<powerValue>\\d+(:?\\.\\d+)?)(?<powerUnit>\\S+) (?<producedValue>\\d+(:?\\.\\d+)?)(?<producedUnit>\\S+)$");
public static final String PHOTOVOLTAIC_POWER = "electricity.photovoltaic.power";
private static final Pattern REGEX = Pattern.compile("^(?<serial>\\S+) (?<epochSeconds>\\d+) (?<powerValue>\\d+(:?\\.\\d+)?)(?<powerUnit>\\S+) (?<energyValue>\\d+(:?\\.\\d+)?)(?<energyUnit>\\S+)$");
private final ApplicationEventPublisher applicationEventPublisher;
@ -40,10 +44,16 @@ public class PhotovoltaicReceiver {
final String serial = matcher.group("serial");
final ZonedDateTime date = ZDT(Long.parseLong(matcher.group("epochSeconds")));
final double producedValue = Double.parseDouble(matcher.group("producedValue"));
final String producedUnit = matcher.group("producedUnit");
final double producedKWh = energyToKWh(producedValue, producedUnit);
applicationEventPublisher.publishEvent(new ConsumptionEvent(PHOTOVOLTAIC_ENERGY_SERIES_NAME, "kWh", true, serial, date, producedKWh));
final double energyValue = Double.parseDouble(matcher.group("energyValue"));
final String energyUnit = matcher.group("energyUnit");
final double energy = energyToKWh(energyValue, energyUnit);
applicationEventPublisher.publishEvent(new ConsumptionEvent(PHOTOVOLTAIC_ENERGY, "kWh", true, serial, date, energy));
final double powerValue = Double.parseDouble(matcher.group("powerValue"));
final String powerUnit = matcher.group("powerUnit");
final double power = powerToW(powerValue, powerUnit);
applicationEventPublisher.publishEvent(new MeasureEvent(PHOTOVOLTAIC_POWER, "W", date, power));
}
}

View File

@ -37,12 +37,10 @@ public class Series {
@ToString.Exclude
private Period period;
@Setter
@NonNull
@Column(nullable = false)
private ZonedDateTime lastDate;
@Setter
@Column(nullable = false)
private double lastValue;
@ -58,4 +56,9 @@ public class Series {
this.lastValue = value;
}
public void update(@NonNull final ZonedDateTime date, final double value) {
lastDate = date;
lastValue = mode.update(lastValue, value);
}
}

View File

@ -1,48 +1,26 @@
package de.ph87.data.series;
import jakarta.annotation.Nullable;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
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.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.stream.Stream;
import java.util.List;
@Slf4j
@Controller
@Transactional
@CrossOrigin
@RestController
@RequiredArgsConstructor
@RequestMapping("Series")
public class SeriesController {
private final SeriesRepository seriesRepository;
private final SeriesService seriesService;
@GetMapping("/")
public String index(@NonNull final Model model) {
final Stream<Series> series = seriesRepository.findAll().stream().sorted(Comparator.comparing(Series::getName));
model.addAttribute("series", series);
return "index";
}
@Nullable
public static String numberFormat(@Nullable final Double value, final int decimals) {
if (value == null) {
return null;
}
return "%%.%df".formatted(decimals).formatted(value);
}
@Nullable
public static String dateTimeFormat(@Nullable final ZonedDateTime date) {
if (date == null) {
return null;
}
return date.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
@GetMapping("findAll")
public List<SeriesDto> findAll() {
return seriesService.findAllDto();
}
}

View File

@ -0,0 +1,57 @@
package de.ph87.data.series;
import com.fasterxml.jackson.annotation.JsonIgnore;
import de.ph87.data.series.consumption.period.PeriodDto;
import de.ph87.data.web.IWebSocketMessage;
import jakarta.annotation.Nullable;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import java.time.ZonedDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Getter
@ToString
public class SeriesDto implements IWebSocketMessage {
@JsonIgnore
private final List<Object> websocketTopic = List.of("Series");
private final long id;
@NonNull
private final String name;
@NonNull
private final String unit;
@NonNull
private final SeriesMode mode;
@Nullable
@ToString.Exclude
private final PeriodDto period;
@NonNull
private final ZonedDateTime lastDate;
private final double lastValue;
@NonNull
private final Set<String> aliases;
public SeriesDto(@NonNull final Series series) {
this.id = series.getId();
this.name = series.getName();
this.unit = series.getUnit();
this.mode = series.getMode();
this.period = PeriodDto.orNull(series.getPeriod());
this.lastDate = series.getLastDate();
this.lastValue = series.getLastValue();
this.aliases = new HashSet<>(series.getAliases());
}
}

View File

@ -3,10 +3,12 @@ package de.ph87.data.series;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.ZonedDateTime;
import java.util.List;
@Slf4j
@Service
@ -16,13 +18,23 @@ public class SeriesService {
private final SeriesRepository seriesRepository;
private final ApplicationEventPublisher applicationEventPublisher;
@NonNull
public Series getOrCreateByName(@NonNull final String name, @NonNull final SeriesMode mode, @NonNull final ZonedDateTime date, final double value, @NonNull final String unit) {
final Series series = seriesRepository.findByNameOrAliasesContains(name, name).orElseGet(() -> seriesRepository.save(new Series(name, mode, date, value, unit)));
if (series.getMode() != mode) {
throw new RuntimeException("'mode' argument for getOrCreateByName does not match 'mode' of existing Series: mode=%s, series=%s".formatted(mode, series));
}
series.update(date, value);
final SeriesDto dto = new SeriesDto(series);
applicationEventPublisher.publishEvent(dto);
return series;
}
@NonNull
public List<SeriesDto> findAllDto() {
return seriesRepository.findAll().stream().map(SeriesDto::new).toList();
}
}

View File

@ -33,9 +33,6 @@ public class ConsumptionService {
log.debug("Handling ConsumptionEvent: {}", event);
final Series series = seriesService.getOrCreateByName(event.getName(), event.isIncreasing() ? SeriesMode.INCREASING : SeriesMode.DECREASING, event.getDate(), event.getValue(), event.getUnit());
series.setLastDate(event.getDate());
series.setLastValue(series.getMode().update(series.getLastValue(), event.getValue()));
final Period period = periodService.getOrCreatePeriod(series, event);
period.setLastDate(event.getDate());
period.setLastValue(series.getMode().update(period.getLastValue(), event.getValue()));

View File

@ -0,0 +1,46 @@
package de.ph87.data.series.consumption.period;
import jakarta.annotation.Nullable;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import java.time.ZonedDateTime;
@Getter
@ToString
public class PeriodDto {
private final long id;
@NonNull
private final String name;
@NonNull
private final ZonedDateTime firstDate;
private final double firstValue;
@NonNull
private final ZonedDateTime lastDate;
private final double lastValue;
public PeriodDto(@NonNull final Period period) {
this.id = period.getId();
this.name = period.getName();
this.firstDate = period.getFirstDate();
this.firstValue = period.getFirstValue();
this.lastDate = period.getLastDate();
this.lastValue = period.getLastValue();
}
@Nullable
public static PeriodDto orNull(@Nullable final Period period) {
if (period == null) {
return null;
}
return new PeriodDto(period);
}
}

View File

@ -27,9 +27,6 @@ public class CounterService {
log.debug("Handling CounterEvent: {}", event);
final Series series = seriesService.getOrCreateByName(event.getName(), SeriesMode.COUNTER, event.getDate(), event.getCount(), event.getUnit());
series.setLastDate(event.getDate());
series.setLastValue(series.getMode().update(series.getLastValue(), event.getCount()));
for (final Unit unit : Unit.values()) {
final SeriesIntervalKey id = new SeriesIntervalKey(series, unit, event.getDate());
counterRepository.findById(id)

View File

@ -27,9 +27,6 @@ public class MeasureService {
log.debug("Handling MeasureEvent: {}", event);
final Series series = seriesService.getOrCreateByName(event.getName(), SeriesMode.MEASURE, event.getDate(), event.getValue(), event.getUnit());
series.setLastDate(event.getDate());
series.setLastValue(series.getMode().update(series.getLastValue(), event.getValue()));
for (final Unit unit : Unit.values()) {
final SeriesIntervalKey id = new SeriesIntervalKey(series, unit, event.getDate());
measureRepository.findById(id)

View File

@ -0,0 +1,12 @@
package de.ph87.data.web;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.util.List;
public interface IWebSocketMessage {
@JsonIgnore
List<Object> getWebsocketTopic();
}

View File

@ -0,0 +1,39 @@
package de.ph87.data.web;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.resource.PathResourceResolver;
import java.io.IOException;
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**").allowedOrigins("*").allowedMethods("*");
}
@Override
public void addResourceHandlers(final ResourceHandlerRegistry registry) {
registry
.addResourceHandler("/**")
.addResourceLocations("classpath:/resources/")
.resourceChain(true)
.addResolver(new PathResourceResolver() {
protected Resource getResource(@NonNull String resourcePath, @NonNull Resource roomLocation) throws IOException {
final Resource requestedResource = roomLocation.createRelative(resourcePath);
return requestedResource.exists() && requestedResource.isReadable() ? requestedResource : new ClassPathResource("/resources/index.html");
}
});
}
}

View File

@ -0,0 +1,35 @@
package de.ph87.data.web;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@CrossOrigin
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
public static final String DESTINATION = "";
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOrigins("*");
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker(DESTINATION).setHeartbeatValue(new long[]{2000, 2000}).setTaskScheduler(heartBeatScheduler());
}
@Bean
public TaskScheduler heartBeatScheduler() {
return new ThreadPoolTaskScheduler();
}
}

View File

@ -0,0 +1,30 @@
package de.ph87.data.web;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.lang.NonNull;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class WebSocketService {
private final SimpMessageSendingOperations simpMessageSendingOperations;
@EventListener(IWebSocketMessage.class)
public void send(@NonNull final IWebSocketMessage message) {
String prefix = "";
String topic = "";
for (int i = 0; i < message.getWebsocketTopic().size(); i++) {
topic += prefix + message.getWebsocketTopic().get(i);
simpMessageSendingOperations.convertAndSend(topic, message);
prefix = "/";
}
}
}

View File

@ -1,50 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta http-equiv="refresh" content="5">
<title>Data</title>
<style>
.section {
margin: 10px;
}
table {
font-family: monospace;
border-collapse: collapse;
}
td, th {
border: 2px solid white;
padding: 10px;
background-color: lightgray;
}
.value {
text-align: right;
border-right: none;
padding-right: 0;
}
.unit {
border-left: none;
}
</style>
</head>
<body>
<div class="section">
<table>
<tr th:each="serie: ${series}">
<td class="name" th:text="${serie.name}"></td>
<td class="mode" th:text="${serie.mode}"></td>
<td class="value" th:text="${@seriesController.numberFormat(serie.lastValue, 1)}"></td>
<td class="unit" th:text="${serie.unit}"></td>
<td class="date" th:text="${@seriesController.dateTimeFormat(serie.lastDate)}"></td>
</tr>
</table>
</div>
</body>
</html>