angular ui + websocket
This commit is contained in:
parent
297e4b4029
commit
21956c0d28
68
pom.xml
68
pom.xml
@ -35,16 +35,6 @@
|
|||||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
</dependency>
|
</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>
|
<dependency>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
@ -75,11 +65,45 @@
|
|||||||
<build>
|
<build>
|
||||||
<finalName>${project.artifactId}</finalName>
|
<finalName>${project.artifactId}</finalName>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.codehaus.mojo</groupId>
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
<artifactId>exec-maven-plugin</artifactId>
|
<artifactId>exec-maven-plugin</artifactId>
|
||||||
<version>3.0.0</version>
|
<version>3.0.0</version>
|
||||||
<executions>
|
<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>
|
<execution>
|
||||||
<id>scp</id>
|
<id>scp</id>
|
||||||
<phase>package</phase>
|
<phase>package</phase>
|
||||||
@ -96,8 +120,32 @@
|
|||||||
</arguments>
|
</arguments>
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</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>
|
</executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
|
|||||||
13
src/main/angular/.editorconfig
Normal file
13
src/main/angular/.editorconfig
Normal 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
42
src/main/angular/.gitignore
vendored
Normal 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
|
||||||
27
src/main/angular/README.md
Normal file
27
src/main/angular/README.md
Normal 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.
|
||||||
130
src/main/angular/angular.json
Normal file
130
src/main/angular/angular.json
Normal 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
14611
src/main/angular/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
src/main/angular/package.json
Normal file
39
src/main/angular/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/main/angular/public/favicon.svg
Normal file
20
src/main/angular/public/favicon.svg
Normal 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 |
13
src/main/angular/src/app/api/Order.ts
Normal file
13
src/main/angular/src/app/api/Order.ts
Normal 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,
|
||||||
|
) {
|
||||||
|
// -
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
28
src/main/angular/src/app/api/Page.ts
Normal file
28
src/main/angular/src/app/api/Page.ts
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
65
src/main/angular/src/app/api/Sort.ts
Normal file
65
src/main/angular/src/app/api/Sort.ts
Normal 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 = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
75
src/main/angular/src/app/api/api.service.ts
Normal file
75
src/main/angular/src/app/api/api.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
21
src/main/angular/src/app/api/series/IValue.ts
Normal file
21
src/main/angular/src/app/api/series/IValue.ts
Normal 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;
|
||||||
|
|
||||||
|
}
|
||||||
40
src/main/angular/src/app/api/series/Series.ts
Normal file
40
src/main/angular/src/app/api/series/Series.ts
Normal 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']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
68
src/main/angular/src/app/api/series/Value.ts
Normal file
68
src/main/angular/src/app/api/series/Value.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
26
src/main/angular/src/app/api/series/series.service.ts
Normal file
26
src/main/angular/src/app/api/series/series.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
9
src/main/angular/src/app/api/types.ts
Normal file
9
src/main/angular/src/app/api/types.ts
Normal 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('/');
|
||||||
|
}
|
||||||
69
src/main/angular/src/app/api/validators.ts
Normal file
69
src/main/angular/src/app/api/validators.ts
Normal 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));
|
||||||
|
}
|
||||||
17
src/main/angular/src/app/api/ws.ts
Normal file
17
src/main/angular/src/app/api/ws.ts
Normal 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;
|
||||||
|
}
|
||||||
1
src/main/angular/src/app/app.component.html
Normal file
1
src/main/angular/src/app/app.component.html
Normal file
@ -0,0 +1 @@
|
|||||||
|
<router-outlet/>
|
||||||
1
src/main/angular/src/app/app.component.less
Normal file
1
src/main/angular/src/app/app.component.less
Normal file
@ -0,0 +1 @@
|
|||||||
|
@import "../common";
|
||||||
13
src/main/angular/src/app/app.component.ts
Normal file
13
src/main/angular/src/app/app.component.ts
Normal 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 {
|
||||||
|
|
||||||
|
}
|
||||||
23
src/main/angular/src/app/app.config.ts
Normal file
23
src/main/angular/src/app/app.config.ts
Normal 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},
|
||||||
|
],
|
||||||
|
};
|
||||||
7
src/main/angular/src/app/app.routes.ts
Normal file
7
src/main/angular/src/app/app.routes.ts
Normal 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: '/'},
|
||||||
|
];
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
<div class="width100">
|
||||||
|
<app-values-tile title="Elektrizität" [seriesList]="getElectricitySeries()"></app-values-tile>
|
||||||
|
</div>
|
||||||
@ -0,0 +1 @@
|
|||||||
|
@import "../../../common";
|
||||||
@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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>
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
2
src/main/angular/src/common.less
Normal file
2
src/main/angular/src/common.less
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
@import "states";
|
||||||
|
@import "widths";
|
||||||
4
src/main/angular/src/config.less
Normal file
4
src/main/angular/src/config.less
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
@font: 5vw;
|
||||||
|
@fontDesktop: 18px;
|
||||||
|
|
||||||
|
@space: 0.5em;
|
||||||
7
src/main/angular/src/environments/environment.prod.ts
Normal file
7
src/main/angular/src/environments/environment.prod.ts
Normal 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:",
|
||||||
|
};
|
||||||
8
src/main/angular/src/environments/environment.ts
Normal file
8
src/main/angular/src/environments/environment.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
host: window.location.host.split(":")[0],
|
||||||
|
port: 8080,
|
||||||
|
base: '',
|
||||||
|
secure: window.location.protocol === "https:",
|
||||||
|
};
|
||||||
|
|
||||||
14
src/main/angular/src/index.html
Normal file
14
src/main/angular/src/index.html
Normal 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>
|
||||||
6
src/main/angular/src/main.ts
Normal file
6
src/main/angular/src/main.ts
Normal 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));
|
||||||
6
src/main/angular/src/states.less
Normal file
6
src/main/angular/src/states.less
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
@import "config";
|
||||||
|
|
||||||
|
.invalid, .invalid * {
|
||||||
|
color: lightgray;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
21
src/main/angular/src/styles.less
Normal file
21
src/main/angular/src/styles.less
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
29
src/main/angular/src/widths.less
Normal file
29
src/main/angular/src/widths.less
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
15
src/main/angular/tsconfig.app.json
Normal file
15
src/main/angular/tsconfig.app.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
33
src/main/angular/tsconfig.json
Normal file
33
src/main/angular/tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/main/angular/tsconfig.spec.json
Normal file
15
src/main/angular/tsconfig.spec.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -2,8 +2,8 @@ package de.ph87.data.electricity;
|
|||||||
|
|
||||||
public class ElectricityHelpers {
|
public class ElectricityHelpers {
|
||||||
|
|
||||||
public static double energyToKWh(final double energy, final String energyUnit) {
|
public static double energyToKWh(final double energy, final String isUnit) {
|
||||||
return switch (energyUnit) {
|
return switch (isUnit) {
|
||||||
case "mWh" -> energy / 1000000;
|
case "mWh" -> energy / 1000000;
|
||||||
case "Wh" -> energy / 1000;
|
case "Wh" -> energy / 1000;
|
||||||
case "kWh" -> energy;
|
case "kWh" -> energy;
|
||||||
@ -14,8 +14,8 @@ public class ElectricityHelpers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unused")
|
@SuppressWarnings("unused")
|
||||||
public static double powerToW(final double power, final String powerUnit) {
|
public static double powerToW(final double power, final String isUnit) {
|
||||||
return switch (powerUnit) {
|
return switch (isUnit) {
|
||||||
case "mW" -> power / 1000;
|
case "mW" -> power / 1000;
|
||||||
case "W" -> power;
|
case "W" -> power;
|
||||||
case "kW" -> power * 1000;
|
case "kW" -> power * 1000;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import de.ph87.data.mqtt.MqttEvent;
|
import de.ph87.data.mqtt.MqttEvent;
|
||||||
import de.ph87.data.series.consumption.ConsumptionEvent;
|
import de.ph87.data.series.consumption.ConsumptionEvent;
|
||||||
|
import de.ph87.data.series.measure.MeasureEvent;
|
||||||
import lombok.NonNull;
|
import lombok.NonNull;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@ -12,15 +13,18 @@ import org.springframework.context.event.EventListener;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import static de.ph87.data.electricity.ElectricityHelpers.energyToKWh;
|
import static de.ph87.data.electricity.ElectricityHelpers.energyToKWh;
|
||||||
|
import static de.ph87.data.electricity.ElectricityHelpers.powerToW;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class GridReceiver {
|
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;
|
private final ApplicationEventPublisher applicationEventPublisher;
|
||||||
|
|
||||||
@ -40,11 +44,14 @@ public class GridReceiver {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final double purchaseKWh = energyToKWh(inbound.purchaseWh, "Wh");
|
final double purchase = energyToKWh(inbound.purchaseWh, "Wh");
|
||||||
applicationEventPublisher.publishEvent(new ConsumptionEvent(GRID_PURCHASE_SERIES_NAME, "kWh", true, inbound.meter, inbound.date, purchaseKWh));
|
applicationEventPublisher.publishEvent(new ConsumptionEvent(GRID_PURCHASE, "kWh", true, inbound.meter, inbound.date, purchase));
|
||||||
|
|
||||||
final double deliveryKWh = energyToKWh(inbound.deliveryWh, "Wh");
|
final double delivery = energyToKWh(inbound.deliveryWh, "Wh");
|
||||||
applicationEventPublisher.publishEvent(new ConsumptionEvent(GRID_DELIVERY_SERIES_NAME, "kWh", true, inbound.meter, inbound.date, deliveryKWh));
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package de.ph87.data.electricity.photovoltaic;
|
|||||||
|
|
||||||
import de.ph87.data.mqtt.MqttEvent;
|
import de.ph87.data.mqtt.MqttEvent;
|
||||||
import de.ph87.data.series.consumption.ConsumptionEvent;
|
import de.ph87.data.series.consumption.ConsumptionEvent;
|
||||||
|
import de.ph87.data.series.measure.MeasureEvent;
|
||||||
import lombok.NonNull;
|
import lombok.NonNull;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.common.DateTimeHelpers.ZDT;
|
||||||
import static de.ph87.data.electricity.ElectricityHelpers.energyToKWh;
|
import static de.ph87.data.electricity.ElectricityHelpers.energyToKWh;
|
||||||
|
import static de.ph87.data.electricity.ElectricityHelpers.powerToW;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class PhotovoltaicReceiver {
|
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;
|
private final ApplicationEventPublisher applicationEventPublisher;
|
||||||
|
|
||||||
@ -40,10 +44,16 @@ public class PhotovoltaicReceiver {
|
|||||||
|
|
||||||
final String serial = matcher.group("serial");
|
final String serial = matcher.group("serial");
|
||||||
final ZonedDateTime date = ZDT(Long.parseLong(matcher.group("epochSeconds")));
|
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 energyValue = Double.parseDouble(matcher.group("energyValue"));
|
||||||
final double producedKWh = energyToKWh(producedValue, producedUnit);
|
final String energyUnit = matcher.group("energyUnit");
|
||||||
applicationEventPublisher.publishEvent(new ConsumptionEvent(PHOTOVOLTAIC_ENERGY_SERIES_NAME, "kWh", true, serial, date, producedKWh));
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,12 +37,10 @@ public class Series {
|
|||||||
@ToString.Exclude
|
@ToString.Exclude
|
||||||
private Period period;
|
private Period period;
|
||||||
|
|
||||||
@Setter
|
|
||||||
@NonNull
|
@NonNull
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private ZonedDateTime lastDate;
|
private ZonedDateTime lastDate;
|
||||||
|
|
||||||
@Setter
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private double lastValue;
|
private double lastValue;
|
||||||
|
|
||||||
@ -58,4 +56,9 @@ public class Series {
|
|||||||
this.lastValue = value;
|
this.lastValue = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void update(@NonNull final ZonedDateTime date, final double value) {
|
||||||
|
lastDate = date;
|
||||||
|
lastValue = mode.update(lastValue, value);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,48 +1,26 @@
|
|||||||
package de.ph87.data.series;
|
package de.ph87.data.series;
|
||||||
|
|
||||||
import jakarta.annotation.Nullable;
|
|
||||||
import lombok.NonNull;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.web.bind.annotation.CrossOrigin;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.ui.Model;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
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.util.List;
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.stream.Stream;
|
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Controller
|
@CrossOrigin
|
||||||
@Transactional
|
@RestController
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@RequestMapping("Series")
|
||||||
public class SeriesController {
|
public class SeriesController {
|
||||||
|
|
||||||
private final SeriesRepository seriesRepository;
|
private final SeriesService seriesService;
|
||||||
|
|
||||||
@GetMapping("/")
|
@GetMapping("findAll")
|
||||||
public String index(@NonNull final Model model) {
|
public List<SeriesDto> findAll() {
|
||||||
final Stream<Series> series = seriesRepository.findAll().stream().sorted(Comparator.comparing(Series::getName));
|
return seriesService.findAllDto();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
57
src/main/java/de/ph87/data/series/SeriesDto.java
Normal file
57
src/main/java/de/ph87/data/series/SeriesDto.java
Normal 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -3,10 +3,12 @@ package de.ph87.data.series;
|
|||||||
import lombok.NonNull;
|
import lombok.NonNull;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.ZonedDateTime;
|
import java.time.ZonedDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@ -16,13 +18,23 @@ public class SeriesService {
|
|||||||
|
|
||||||
private final SeriesRepository seriesRepository;
|
private final SeriesRepository seriesRepository;
|
||||||
|
|
||||||
|
private final ApplicationEventPublisher applicationEventPublisher;
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
public Series getOrCreateByName(@NonNull final String name, @NonNull final SeriesMode mode, @NonNull final ZonedDateTime date, final double value, @NonNull final String unit) {
|
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)));
|
final Series series = seriesRepository.findByNameOrAliasesContains(name, name).orElseGet(() -> seriesRepository.save(new Series(name, mode, date, value, unit)));
|
||||||
if (series.getMode() != mode) {
|
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));
|
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;
|
return series;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
public List<SeriesDto> findAllDto() {
|
||||||
|
return seriesRepository.findAll().stream().map(SeriesDto::new).toList();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,9 +33,6 @@ public class ConsumptionService {
|
|||||||
log.debug("Handling ConsumptionEvent: {}", event);
|
log.debug("Handling ConsumptionEvent: {}", event);
|
||||||
|
|
||||||
final Series series = seriesService.getOrCreateByName(event.getName(), event.isIncreasing() ? SeriesMode.INCREASING : SeriesMode.DECREASING, event.getDate(), event.getValue(), event.getUnit());
|
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);
|
final Period period = periodService.getOrCreatePeriod(series, event);
|
||||||
period.setLastDate(event.getDate());
|
period.setLastDate(event.getDate());
|
||||||
period.setLastValue(series.getMode().update(period.getLastValue(), event.getValue()));
|
period.setLastValue(series.getMode().update(period.getLastValue(), event.getValue()));
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -27,9 +27,6 @@ public class CounterService {
|
|||||||
log.debug("Handling CounterEvent: {}", event);
|
log.debug("Handling CounterEvent: {}", event);
|
||||||
|
|
||||||
final Series series = seriesService.getOrCreateByName(event.getName(), SeriesMode.COUNTER, event.getDate(), event.getCount(), event.getUnit());
|
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()) {
|
for (final Unit unit : Unit.values()) {
|
||||||
final SeriesIntervalKey id = new SeriesIntervalKey(series, unit, event.getDate());
|
final SeriesIntervalKey id = new SeriesIntervalKey(series, unit, event.getDate());
|
||||||
counterRepository.findById(id)
|
counterRepository.findById(id)
|
||||||
|
|||||||
@ -27,9 +27,6 @@ public class MeasureService {
|
|||||||
log.debug("Handling MeasureEvent: {}", event);
|
log.debug("Handling MeasureEvent: {}", event);
|
||||||
|
|
||||||
final Series series = seriesService.getOrCreateByName(event.getName(), SeriesMode.MEASURE, event.getDate(), event.getValue(), event.getUnit());
|
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()) {
|
for (final Unit unit : Unit.values()) {
|
||||||
final SeriesIntervalKey id = new SeriesIntervalKey(series, unit, event.getDate());
|
final SeriesIntervalKey id = new SeriesIntervalKey(series, unit, event.getDate());
|
||||||
measureRepository.findById(id)
|
measureRepository.findById(id)
|
||||||
|
|||||||
12
src/main/java/de/ph87/data/web/IWebSocketMessage.java
Normal file
12
src/main/java/de/ph87/data/web/IWebSocketMessage.java
Normal 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();
|
||||||
|
|
||||||
|
}
|
||||||
39
src/main/java/de/ph87/data/web/WebConfig.java
Normal file
39
src/main/java/de/ph87/data/web/WebConfig.java
Normal 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
35
src/main/java/de/ph87/data/web/WebSocketConfig.java
Normal file
35
src/main/java/de/ph87/data/web/WebSocketConfig.java
Normal 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
30
src/main/java/de/ph87/data/web/WebSocketService.java
Normal file
30
src/main/java/de/ph87/data/web/WebSocketService.java
Normal 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 = "/";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
Loading…
Reference in New Issue
Block a user