back to the roots: removed config, removed log, reduced Topic

This commit is contained in:
Patrick Haßel 2025-10-28 14:34:24 +01:00
parent 479b5ff76e
commit 278fe60906
81 changed files with 1270 additions and 1116 deletions

View File

@ -8,4 +8,4 @@ spring.jpa.hibernate.ddl-auto=update
#-
spring.jackson.serialization.indent_output=true
#-
de.ph87.knx.mqtt.uri=tcp://10.255.0.1:1883
de.ph87.data.mqtt.uri=tcp://10.0.0.50:1883

View File

@ -9,6 +9,7 @@ insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
# noinspection EditorConfigKeyCorrectness
quote_type = single
ij_typescript_use_double_quotes = false

View File

@ -14,6 +14,9 @@
"@angular/forms": "^20.3.0",
"@angular/platform-browser": "^20.3.0",
"@angular/router": "^20.3.0",
"@fortawesome/angular-fontawesome": "^3.0.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@stomp/ng2-stompjs": "^8.0.0",
"@stomp/stompjs": "^7.2.1",
"rxjs": "~7.8.0",
@ -1344,6 +1347,64 @@
"node": ">=18"
}
},
"node_modules/@fortawesome/angular-fontawesome": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@fortawesome/angular-fontawesome/-/angular-fontawesome-3.0.0.tgz",
"integrity": "sha512-+8Dd6DoJnqArfrZ5NvjHyRL64IIkTigXclbOOcFdYQ8/WFERQUDaEU6SAV8Q0JBpJhMS1McED7YCOCAE6SIVyA==",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.0.0",
"tslib": "^2.8.1"
},
"peerDependencies": {
"@angular/core": "^20.0.0"
}
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz",
"integrity": "sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.1.0.tgz",
"integrity": "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA==",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-7.1.0.tgz",
"integrity": "sha512-0e2fdEyB4AR+e6kU4yxwA/MonnYcw/CsMEP9lH82ORFi9svA6/RhDyhxIv5mlJaldmaHLLYVTb+3iEr+PDSZuQ==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.1.0.tgz",
"integrity": "sha512-Udu3K7SzAo9N013qt7qmm22/wo2hADdheXtBfxFTecp+ogsc0caQNRKEb7pkvvagUGOpG9wJC1ViH6WXs8oXIA==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@inquirer/ansi": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.1.tgz",

View File

@ -32,7 +32,10 @@
"tslib": "^2.3.0",
"zone.js": "~0.15.0",
"@stomp/ng2-stompjs": "^8.0.0",
"@stomp/stompjs": "^7.2.1"
"@stomp/stompjs": "^7.2.1",
"@fortawesome/angular-fontawesome": "^3.0.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0"
},
"devDependencies": {
"@angular/build": "^20.3.7",

View File

@ -1 +1,13 @@
<div class="MainMenu NoUserSelect">
<div class="MainMenuBar">
<div class="MainMenuButton" (click)="showDrawer = !showDrawer">
<fa-icon [icon]="faBars"></fa-icon>
</div>
</div>
</div>
<div class="MainMenuDrawer NoUserSelect" [hidden]="!showDrawer">
<div class="MainMenuItem" routerLink="Location" routerLinkActive="MainMenuItemActive">Orte</div>
</div>
<router-outlet/>

View File

@ -0,0 +1,46 @@
.MainMenu {
border-bottom: 1px solid #888;
background-color: lightsteelblue;
overflow: visible;
.MainMenuBar {
display: flex;
padding: 0.25em;
.MainMenuButton {
padding: 0.25em;
border: 1px solid #888;
border-radius: 0.25em;
}
.MainMenuButton:hover {
background-color: lightskyblue;
}
}
}
.MainMenuDrawer {
margin: 0.25em;
border-radius: 0.1em;
background-color: lightsteelblue;
box-shadow: 0 0 0.25em black;
.MainMenuItem {
padding: 0.5em;
}
.MainMenuItem:not(:last-child) {
border-bottom: 1px solid #888;
}
.MainMenuItem:hover {
background-color: lightskyblue;
}
.MainMenuItemActive {
font-weight: bold;
text-shadow: 0 0 0.25em white;
}
}

View File

@ -1,7 +1,10 @@
import {Routes} from '@angular/router';
import {LocationList} from './location/list/location-list';
import {LocationDetail} from './location/detail/location-detail';
export const routes: Routes = [
{path: 'LocationList', component: LocationList},
{path: '**', redirectTo: 'LocationList'}
{path: 'Location/:id', component: LocationDetail},
{path: 'Location', component: LocationList},
{path: '**', redirectTo: 'Location'},
{path: '**', redirectTo: 'Location'},
];

View File

@ -1,11 +1,19 @@
import {Component} from '@angular/core';
import {RouterOutlet} from '@angular/router';
import {RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router';
import {FaIconComponent} from '@fortawesome/angular-fontawesome';
import {faBars, faBurger} from '@fortawesome/free-solid-svg-icons';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
imports: [RouterOutlet, FaIconComponent, RouterLink, RouterLinkActive],
templateUrl: './app.html',
styleUrl: './app.less'
})
export class App {
protected readonly faBurger = faBurger;
protected readonly faBars = faBars;
protected showDrawer: boolean = false;
}

View File

@ -1,4 +1,5 @@
import {validateNumber, validateString} from '../common';
import {or, validateNumber, validateString} from '../common';
import {Series} from '../series/Series';
export class Location {
@ -7,6 +8,10 @@ export class Location {
readonly name: string,
readonly latitude: number,
readonly longitude: number,
readonly purchase: Series | null,
readonly delivery: Series | null,
readonly produce: Series | null,
readonly power: Series | null,
) {
//
}
@ -17,6 +22,10 @@ export class Location {
validateString(json.name),
validateNumber(json.latitude),
validateNumber(json.longitude),
or(json.purchase, Series.fromJson, null),
or(json.delivery, Series.fromJson, null),
or(json.produce, Series.fromJson, null),
or(json.power, Series.fromJson, null),
);
}

View File

@ -0,0 +1,9 @@
@if (location) {
<app-text [initial]="location.name" (onChange)="locationService.name(location, $event, update)"></app-text>
<app-number [initial]="location.latitude" (onChange)="locationService.latitude(location, $event, update)" unit="°"></app-number>
<app-number [initial]="location.longitude" (onChange)="locationService.longitude(location, $event, update)" unit="°"></app-number>
<app-series-select [initial]="location.purchase" (onChange)="locationService.purchase(location, $event, update)" [list]="filterEnergy()"></app-series-select>
<app-series-select [initial]="location.delivery" (onChange)="locationService.delivery(location, $event, update)" [list]="filterEnergy()"></app-series-select>
<app-series-select [initial]="location.produce" (onChange)="locationService.produce(location, $event, update)" [list]="filterEnergy()"></app-series-select>
<app-series-select [initial]="location.power" (onChange)="locationService.power(location, $event, update)" [list]="filterPower()"></app-series-select>
}

View File

@ -0,0 +1,56 @@
import {Component, OnInit} from '@angular/core';
import {LocationService} from '../location-service';
import {ActivatedRoute} from '@angular/router';
import {Location} from '../Location';
import {Text} from '../../shared/text/text';
import {Number} from '../../shared/number/number';
import {SeriesSelect, SeriesType} from '../../series/select/series-select';
import {Series} from '../../series/Series';
import {SeriesService} from '../../series/series-service';
@Component({
selector: 'app-location-detail',
imports: [
Text,
Number,
SeriesSelect
],
templateUrl: './location-detail.html',
styleUrl: './location-detail.less',
})
export class LocationDetail implements OnInit {
protected location: Location | null = null;
private series: Series[] = [];
constructor(
readonly locationService: LocationService,
readonly seriesService: SeriesService,
readonly activatedRoute: ActivatedRoute,
) {
//
}
ngOnInit(): void {
this.activatedRoute.params.subscribe(params => {
this.locationService.getById(params['id'], location => this.location = location);
});
this.seriesService.findAll(list => this.series = list);
}
protected readonly update = (location: Location): void => {
if (this.location?.id === location.id) {
this.location = location;
}
};
protected readonly filterEnergy = (): Series[] => {
return this.series.filter(series => series.type === SeriesType.DELTA && series.unit === 'kWh');
};
protected readonly filterPower = (): Series[] => {
return this.series.filter(series => series.type === SeriesType.VARYING && series.unit === 'W');
};
}

View File

@ -1,6 +1,6 @@
<div class="LocationList">
<div class="List LocationList NoUserSelect">
@for (location of list; track location.id) {
<div class="Location">
<div class="ListItem Location" routerLink="/Location/{{location.id}}">
{{ location.name }}
</div>
}

View File

@ -1,10 +1,13 @@
import {Component, OnInit} from '@angular/core';
import {LocationService} from '../location-service';
import {Location} from '../Location';
import {RouterLink} from '@angular/router';
@Component({
selector: 'app-location-list',
imports: [],
imports: [
RouterLink
],
templateUrl: './location-list.html',
styleUrl: './location-list.less',
})

View File

@ -1,6 +1,7 @@
import {Injectable} from '@angular/core';
import {ApiService, CrudService, Next, WebsocketService} from '../common';
import {Location} from './Location'
import {Series} from '../series/Series';
@Injectable({
providedIn: 'root'
@ -15,4 +16,36 @@ export class LocationService extends CrudService<Location> {
this.getList(['findAll'], next);
}
getById(id: number, next: Next<Location>) {
this.getSingle([id, 'byId'], next);
}
name(location: Location, name: string, next?: Next<Location>) {
this.postSingle([location.id, 'name'], name, next);
}
latitude(location: Location, latitude: number, next?: Next<Location>) {
this.postSingle([location.id, 'latitude'], latitude, next);
}
longitude(location: Location, longitude: number, next?: Next<Location>) {
this.postSingle([location.id, 'longitude'], longitude, next);
}
purchase(location: Location, purchase: Series | null, next?: Next<Location>) {
this.postSingle([location.id, 'purchase'], purchase?.id, next);
}
delivery(location: Location, delivery: Series | null, next?: Next<Location>) {
this.postSingle([location.id, 'delivery'], delivery?.id, next);
}
produce(location: Location, produce: Series | null, next?: Next<Location>) {
this.postSingle([location.id, 'produce'], produce?.id, next);
}
power(location: Location, power: Series | null, next?: Next<Location>) {
this.postSingle([location.id, 'power'], power?.id, next);
}
}

View File

@ -0,0 +1,24 @@
import {validateEnum, validateNumber, validateString} from "../common";
import {SeriesType} from "./select/series-select";
export class Series {
constructor(
readonly id: number,
readonly name: string,
readonly unit: string,
readonly type: SeriesType,
) {
//
}
static fromJson(json: any): Series {
return new Series(
validateNumber(json.id),
validateString(json.name),
validateString(json.unit),
validateEnum(json.type, SeriesType),
);
}
}

View File

@ -0,0 +1,27 @@
<div
class="container NoUserSelect"
[ngClass]="classes()"
(mouseenter)="showPen = true"
(mouseleave)="showPen = false"
>
<div class="value" (click)="start()">
@if (editing) {
<select
#input
[(ngModel)]="model"
(blur)="apply()"
(keydown.enter)="apply()"
(keydown.escape)="cancel()"
>
@for (series of list; track series.id) {
<option [ngValue]="series">{{ series.name }}</option>
}
</select>
} @else {
{{ initial?.name || '&nbsp;' }}
}
</div>
@if (editing || showPen) {
<fa-icon [icon]="faPen"></fa-icon>
}
</div>

View File

@ -0,0 +1,84 @@
import {Component, ElementRef, EventEmitter, Input, Output, ViewChild} from '@angular/core';
import {FaIconComponent} from '@fortawesome/angular-fontawesome';
import {FormsModule} from '@angular/forms';
import {NgClass} from '@angular/common';
import {faPen} from '@fortawesome/free-solid-svg-icons';
import {Series} from '../Series';
export enum SeriesType {
BOOL = 'BOOL',
DELTA = 'DELTA',
VARYING = 'VARYING',
}
@Component({
selector: 'app-series-select',
imports: [
FaIconComponent,
FormsModule,
NgClass
],
templateUrl: './series-select.html',
styleUrl: './series-select.less',
})
export class SeriesSelect {
@ViewChild('input')
protected readonly input!: ElementRef;
private _initial: Series | null = null;
@Input()
protected allowEmpty: boolean = true;
@Input()
list: Series[] = [];
@Output()
readonly onChange = new EventEmitter<Series | null>();
protected showPen: boolean = false;
protected model: Series | null = null;
protected editing: boolean = false;
@Input()
set initial(value: Series | null) {
this._initial = value;
if (!this.editing) {
this.model = this.initial;
}
}
get initial(): Series | null {
return this._initial;
}
protected start() {
this.editing = true;
setTimeout(() => this.input.nativeElement.focus(), 0);
}
protected apply() {
if (this.model !== this.initial) {
this.onChange.emit(this.model);
}
this.editing = false;
}
protected cancel() {
this.model = this.initial;
this.editing = false;
}
protected classes(): {} {
return {
"unchanged": this.editing && this.model === this.initial,
"changed": this.model !== this.initial,
"invalid": !this.allowEmpty && this.model === null,
};
}
protected readonly faPen = faPen;
}

View File

@ -0,0 +1,18 @@
import {Injectable} from '@angular/core';
import {ApiService, CrudService, Next, WebsocketService} from '../common';
import {Series} from './Series';
@Injectable({
providedIn: 'root'
})
export class SeriesService extends CrudService<Series> {
constructor(api: ApiService, ws: WebsocketService) {
super(api, ws, ['Series'], Series.fromJson);
}
findAll(next: Next<Series[]>) {
this.getList(['findAll'], next);
}
}

View File

@ -0,0 +1,28 @@
<div
(click)="start()"
class="container NoUserSelect"
[ngClass]="classes()"
(mouseenter)="showPen = true"
(mouseleave)="showPen = false"
>
<div class="value">
@if (editing) {
<input
#input
type="number"
[(ngModel)]="model"
(blur)="blur()"
(keydown.enter)="apply()"
(keydown.escape)="cancel()"
>
} @else {
{{ initial | number:'0.0-999':locale || '&nbsp;' }}{{ unit }}
}
</div>
@if (!editing && showPen) {
<fa-icon [icon]="faPen"></fa-icon>
}
@if (editing) {
<fa-icon [icon]="faCancel" (mousedown)="cancel()"></fa-icon>
}
</div>

View File

@ -0,0 +1,29 @@
.container {
display: flex;
.value {
flex: 1;
}
}
.container:hover {
background-color: #0002;
}
input {
all: unset;
width: 100%;
}
.unchanged {
background-color: lightgreen !important;
}
.invalid {
background-color: red !important;
}
.changed {
background-color: yellow !important;
}

View File

@ -0,0 +1,91 @@
import {Component, ElementRef, EventEmitter, Inject, Input, LOCALE_ID, Output, ViewChild} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {FaIconComponent} from '@fortawesome/angular-fontawesome';
import {faCancel, faPen} from '@fortawesome/free-solid-svg-icons';
import {DecimalPipe, NgClass} from '@angular/common';
@Component({
selector: 'app-number',
imports: [
FormsModule,
FaIconComponent,
NgClass,
DecimalPipe
],
templateUrl: './number.html',
styleUrl: './number.less',
})
export class Number {
protected readonly faCancel = faCancel;
protected readonly faPen = faPen;
@ViewChild('input')
protected readonly input!: ElementRef;
private _initial: number = 0;
@Input()
set initial(value: number) {
this._initial = value;
if (!this.editing) {
this.model = this.initial;
}
}
get initial(): number {
return this._initial;
}
@Input()
unit: string = "";
@Input()
protected validate: (value: number) => boolean = () => true;
@Output()
readonly onChange = new EventEmitter<number>();
protected showPen: boolean = false;
protected model: number = 0;
protected editing: boolean = false;
constructor(
@Inject(LOCALE_ID) readonly locale: string,
) {
//
}
protected readonly start = () => {
this.editing = true;
setTimeout(() => this.input.nativeElement.focus(), 0);
};
protected readonly blur = () => {
setTimeout(this.apply, 0);
};
protected readonly apply = () => {
if (this.model !== this.initial) {
this.onChange.emit(this.model);
}
this.editing = false;
};
protected readonly cancel = () => {
this.model = this.initial;
this.editing = false;
};
protected readonly classes = (): {} => {
return {
"unchanged": this.editing && this.model === this.initial,
"changed": this.model !== this.initial,
"invalid": !this.validate(this.model),
};
};
}

View File

@ -0,0 +1,28 @@
<div
(click)="start()"
class="container NoUserSelect"
[ngClass]="classes()"
(mouseenter)="showPen = true"
(mouseleave)="showPen = false"
>
<div class="value">
@if (editing) {
<input
#input
type="text"
[(ngModel)]="model"
(blur)="blur()"
(keydown.enter)="apply()"
(keydown.escape)="cancel()"
>
} @else {
{{ initial || '&nbsp;' }}
}
</div>
@if (!editing && showPen) {
<fa-icon [icon]="faPen"></fa-icon>
}
@if (editing) {
<fa-icon [icon]="faCancel" (mousedown)="cancel()"></fa-icon>
}
</div>

View File

@ -0,0 +1,29 @@
.container {
display: flex;
.value {
flex: 1;
}
}
.container:hover {
background-color: #0002;
}
input {
all: unset;
width: 100%;
}
.unchanged {
background-color: lightgreen !important;
}
.invalid {
background-color: red !important;
}
.changed {
background-color: yellow !important;
}

View File

@ -0,0 +1,83 @@
import {Component, ElementRef, EventEmitter, Input, Output, ViewChild} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {FaIconComponent} from '@fortawesome/angular-fontawesome';
import {faCancel, faPen} from '@fortawesome/free-solid-svg-icons';
import {NgClass} from '@angular/common';
@Component({
selector: 'app-text',
imports: [
FormsModule,
FaIconComponent,
NgClass
],
templateUrl: './text.html',
styleUrl: './text.less',
})
export class Text {
@ViewChild('input')
protected readonly input!: ElementRef;
private _initial: string = "";
@Input()
protected allowEmpty: boolean = true;
@Input()
protected validate: (value: string) => boolean = () => true;
@Output()
readonly onChange = new EventEmitter<string>();
protected readonly faPen = faPen;
protected showPen: boolean = false;
protected model: string = "";
protected editing: boolean = false;
@Input()
set initial(value: string) {
this._initial = value;
if (!this.editing) {
this.model = this.initial;
}
}
get initial(): string {
return this._initial;
}
protected readonly start = (): void => {
this.editing = true;
setTimeout(() => this.input.nativeElement.focus(), 0);
};
protected readonly blur = (): void => {
setTimeout(this.apply, 0);
};
protected readonly apply = (): void => {
if (this.model !== this.initial) {
this.onChange.emit(this.model);
}
this.editing = false;
};
protected readonly cancel = (): void => {
this.model = this.initial;
this.editing = false;
};
protected readonly classes = (): {} => {
return {
"unchanged": this.editing && this.model === this.initial,
"changed": this.model !== this.initial,
"invalid": (!this.allowEmpty && this.model === '') || !this.validate(this.model),
};
};
protected readonly faCancel = faCancel;
}

View File

@ -1 +1,25 @@
/* You can add global styles to this file, and also import other style files */
body {
position: relative;
height: 100%;
margin: 0;
font-family: sans-serif;
font-size: 5vw;
}
div {
overflow: hidden;
box-sizing: border-box;
}
.List {
overflow-y: scroll;
.ListItem {
padding: 0.5em;
border-bottom: 0.05em solid #ccc;
}
}
.NoUserSelect {
user-select: none;
}

View File

@ -1,5 +1,7 @@
package de.ph87.data;
import de.ph87.data.location.Location;
import de.ph87.data.location.LocationRepository;
import de.ph87.data.plot.Plot;
import de.ph87.data.plot.PlotRepository;
import de.ph87.data.plot.axis.Axis;
@ -41,15 +43,29 @@ public class DemoService {
private final DemoConfig demoConfig;
private final LocationRepository locationRepository;
@Transactional
@EventListener(ApplicationReadyEvent.class)
public void init() {
if (!demoConfig.isEnabled()) {
return;
}
location("Eppelborn", 49.4086, 6.9645);
location("Friedrichsthal", 49.3270, 7.0947);
topics();
}
private void location(@NonNull final String name, final double latitude, final double longitude) {
locationRepository.findByName(name).orElseGet(() -> {
final Location created = new Location();
created.setName(name);
created.setLatitude(latitude);
created.setLongitude(longitude);
return locationRepository.save(created);
});
}
private void topics() {
final Series fallbackRelay0 = series("fallback/relay0", "", SeriesType.BOOL, 5);
topic(
@ -329,17 +345,17 @@ public class DemoService {
}
@NonNull
private Series series(@NonNull final String name, @NonNull final String unit, @NonNull final SeriesType type, final int expectedEverySeconds) {
private Series series(@NonNull final String name, @NonNull final String unit, @NonNull final SeriesType type, final int seconds) {
return seriesRepository
.findByName(name)
.stream()
.peek(existing -> {
existing.setUnit(unit);
existing.setType(type);
existing.setExpectedEverySeconds(expectedEverySeconds);
existing.setSeconds(seconds);
})
.findFirst()
.orElseGet(() -> seriesRepository.save(new Series(name, unit, 1, expectedEverySeconds, type)));
.orElseGet(() -> seriesRepository.save(new Series(name, unit, 1, seconds, type)));
}
private void topic(@NonNull final String name, @NonNull final TopicQuery... queries) {

View File

@ -0,0 +1,5 @@
package de.ph87.data.common;
public enum CrudAction {
CREATED, MODIFIED, DELETED
}

View File

@ -1,85 +0,0 @@
package de.ph87.data.config;
import de.ph87.data.series.data.Interval;
import jakarta.annotation.Nullable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OrderColumn;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.ToString;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@ToString
@NoArgsConstructor
public class Config {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Column(nullable = false)
private boolean hidden = true;
@Column(nullable = false)
private boolean used = true;
@Column(nullable = false)
private boolean unused = true;
@Column(nullable = false)
private boolean ok = true;
@Column(nullable = false)
private boolean error = true;
@Column(nullable = false)
private boolean details = true;
@Nullable
@Column(name = "`interval`")
@Enumerated(EnumType.STRING)
private Interval interval = null;
@Column(nullable = false, name = "`offset`")
private long offset = 0;
@NonNull
@Column(nullable = false)
private String search = "";
@NonNull
@OrderColumn(name = "index")
@ElementCollection(fetch = FetchType.EAGER)
private List<Order> seriesListOrders = new ArrayList<>(List.of(new Order("name", Direction.ASC)));
public Config(@NonNull final ConfigDto dto) {
set(dto);
}
public void set(@NonNull final ConfigDto dto) {
this.hidden = dto.topicList.isHidden();
this.used = dto.topicList.isUsed();
this.unused = dto.topicList.isUnused();
this.ok = dto.topicList.isOk();
this.error = dto.topicList.isError();
this.details = dto.topicList.isDetails();
this.interval = dto.seriesList.getInterval();
this.offset = dto.seriesList.getOffset();
this.search = dto.seriesList.getSearch();
this.seriesListOrders = dto.getSeriesList().orders.stream().map(Order::new).toList();
}
}

View File

@ -1,30 +0,0 @@
package de.ph87.data.config;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@CrossOrigin
@RestController
@RequestMapping("Config")
@RequiredArgsConstructor
public class ConfigController {
private final ConfigService configService;
@GetMapping("get")
public ConfigDto get() {
return configService.get();
}
@PostMapping("set")
public ConfigDto set(@RequestBody @NonNull final ConfigDto inbound) {
return configService.set(inbound);
}
}

View File

@ -1,28 +0,0 @@
package de.ph87.data.config;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.NonNull;
@Data
public class ConfigDto {
public final ConfigTopicListDto topicList;
@NonNull
public final ConfigSeriesListDto seriesList;
public ConfigDto(
@JsonProperty("topicList") @NonNull final ConfigTopicListDto topicList,
@JsonProperty("seriesList") @NonNull final ConfigSeriesListDto seriesList
) {
this.topicList = topicList;
this.seriesList = seriesList;
}
ConfigDto(@NonNull final Config config) {
this.topicList = new ConfigTopicListDto(config);
this.seriesList = ConfigSeriesListDto.fromDB(config);
}
}

View File

@ -1,11 +0,0 @@
package de.ph87.data.config;
import org.springframework.data.repository.ListCrudRepository;
import java.util.Optional;
public interface ConfigRepository extends ListCrudRepository<Config, Long> {
Optional<Config> findFirstBy();
}

View File

@ -1,52 +0,0 @@
package de.ph87.data.config;
import com.fasterxml.jackson.annotation.JsonProperty;
import de.ph87.data.series.data.Interval;
import jakarta.annotation.Nullable;
import jakarta.persistence.Column;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import lombok.Data;
import lombok.NonNull;
import java.util.List;
@Data
public class ConfigSeriesListDto {
@Column
@Nullable
@Enumerated(EnumType.STRING)
public final Interval interval;
@Column(nullable = false)
public final long offset;
@NonNull
public final String search;
public final List<OrderDto> orders;
public ConfigSeriesListDto(
@JsonProperty("interval") @Nullable final Interval interval,
@JsonProperty("offset") final long offset,
@JsonProperty("search") @NonNull final String search,
@JsonProperty("orders") @NonNull final List<OrderDto> orders
) {
this.interval = interval;
this.offset = offset;
this.search = search;
this.orders = orders;
}
@NonNull
public static ConfigSeriesListDto fromDB(@NonNull final Config orders) {
return new ConfigSeriesListDto(
orders.getInterval(),
orders.getOffset(),
orders.getSearch(),
orders.getSeriesListOrders().stream().map(OrderDto::new).toList()
);
}
}

View File

@ -1,26 +0,0 @@
package de.ph87.data.config;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class ConfigService {
private final ConfigRepository configRepository;
@Transactional
public ConfigDto get() {
return new ConfigDto(configRepository.findFirstBy().orElseGet(() -> configRepository.save(new Config())));
}
@Transactional
public ConfigDto set(@NonNull final ConfigDto inbound) {
return new ConfigDto(configRepository.findFirstBy().stream().peek(old -> old.set(inbound)).findFirst().orElseGet(() -> configRepository.save(new Config(inbound))));
}
}

View File

@ -1,47 +0,0 @@
package de.ph87.data.config;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.NonNull;
@Data
public class ConfigTopicListDto {
public final boolean hidden;
public final boolean used;
public final boolean unused;
public final boolean ok;
public final boolean error;
public final boolean details;
public ConfigTopicListDto(
@JsonProperty("hidden") final boolean hidden,
@JsonProperty("used") final boolean used,
@JsonProperty("unused") final boolean unused,
@JsonProperty("ok") final boolean ok,
@JsonProperty("error") final boolean error,
@JsonProperty("details") final boolean details
) {
this.hidden = hidden;
this.used = used;
this.unused = unused;
this.ok = ok;
this.error = error;
this.details = details;
}
ConfigTopicListDto(@NonNull final Config config) {
this.hidden = config.isHidden();
this.used = config.isUsed();
this.unused = config.isUnused();
this.ok = config.isOk();
this.error = config.isError();
this.details = config.isDetails();
}
}

View File

@ -1,5 +0,0 @@
package de.ph87.data.config;
public enum Direction {
ASC, DESC
}

View File

@ -1,37 +0,0 @@
package de.ph87.data.config;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.ToString;
@Getter
@ToString
@Embeddable
@NoArgsConstructor
public class Order {
@NonNull
@Column(nullable = false)
private String property;
@NonNull
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private Direction direction;
public Order(@NonNull final OrderDto dto) {
this.property = dto.property;
this.direction = dto.direction;
}
public Order(@NonNull final String property, @NonNull final Direction direction) {
this.property = property;
this.direction = direction;
}
}

View File

@ -1,29 +0,0 @@
package de.ph87.data.config;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.NonNull;
@Data
public class OrderDto {
@NonNull
public final String property;
@NonNull
public final Direction direction;
public OrderDto(
@JsonProperty("property") @NonNull final String property,
@JsonProperty("direction") @NonNull final Direction direction
) {
this.property = property;
this.direction = direction;
}
public OrderDto(@NonNull final Order order) {
this.property = order.getProperty();
this.direction = order.getDirection();
}
}

View File

@ -0,0 +1,64 @@
package de.ph87.data.location;
import de.ph87.data.series.Series;
import jakarta.annotation.Nullable;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Version;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.Setter;
import lombok.ToString;
@Entity
@Getter
@ToString
@NoArgsConstructor
public class Location {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Version
private long version;
@Setter
@NonNull
@Column(nullable = false)
private String name = "";
@Setter
@Column(nullable = false)
private double latitude;
@Setter
@Column(nullable = false)
private double longitude;
@Setter
@Nullable
@ManyToOne
private Series purchase;
@Setter
@Nullable
@ManyToOne
private Series delivery;
@Setter
@Nullable
@ManyToOne
private Series produce;
@Setter
@Nullable
@ManyToOne
private Series power;
}

View File

@ -0,0 +1,80 @@
package de.ph87.data.location;
import jakarta.annotation.Nullable;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@CrossOrigin
@RestController
@RequiredArgsConstructor
@RequestMapping("Location")
public class LocationController {
private final LocationRepository locationRepository;
private final LocationService locationService;
@GetMapping("findAll")
public List<LocationDto> findAll() {
return locationRepository.findAllDto();
}
@GetMapping("create")
public LocationDto create() {
return locationService.create();
}
@GetMapping("{id}/byId")
public LocationDto byId(@PathVariable final long id) {
return locationRepository.dtoById(id).orElseThrow();
}
@GetMapping("{id}/delete")
public LocationDto delete(@PathVariable final long id) {
return locationService.delete(id);
}
@PostMapping("{id}/name")
public LocationDto name(@PathVariable final long id, @RequestBody(required = false) @Nullable final String name) {
return locationService.name(id, name == null ? "" : name);
}
@PostMapping("{id}/latitude")
public LocationDto latitude(@PathVariable final long id, @RequestBody final double latitude) {
return locationService.latitude(id, latitude);
}
@PostMapping("{id}/longitude")
public LocationDto longitude(@PathVariable final long id, @RequestBody final double longitude) {
return locationService.longitude(id, longitude);
}
@PostMapping("{id}/purchase")
public LocationDto purchase(@PathVariable final long id, @RequestBody(required = false) @Nullable final Long seriesId) {
return locationService.purchase(id, seriesId);
}
@PostMapping("{id}/delivery")
public LocationDto delivery(@PathVariable final long id, @RequestBody(required = false) @Nullable final Long seriesId) {
return locationService.delivery(id, seriesId);
}
@PostMapping("{id}/produce")
public LocationDto produce(@PathVariable final long id, @RequestBody(required = false) @Nullable final Long seriesId) {
return locationService.produce(id, seriesId);
}
@PostMapping("{id}/power")
public LocationDto power(@PathVariable final long id, @RequestBody(required = false) @Nullable final Long seriesId) {
return locationService.power(id, seriesId);
}
}

View File

@ -0,0 +1,47 @@
package de.ph87.data.location;
import de.ph87.data.series.SeriesDto;
import jakarta.annotation.Nullable;
import lombok.Data;
import lombok.NonNull;
import static de.ph87.data.Helpers.map;
@Data
public class LocationDto {
public final long id;
public final long version;
public final String name;
public final double latitude;
public final double longitude;
@Nullable
public final SeriesDto purchase;
@Nullable
public final SeriesDto delivery;
@Nullable
public final SeriesDto produce;
@Nullable
public final SeriesDto power;
public LocationDto(@NonNull final Location location) {
this.id = location.getId();
this.version = location.getVersion();
this.name = location.getName();
this.latitude = location.getLatitude();
this.longitude = location.getLongitude();
this.purchase = map(location.getPurchase(), SeriesDto::new);
this.delivery = map(location.getDelivery(), SeriesDto::new);
this.produce = map(location.getProduce(), SeriesDto::new);
this.power = map(location.getPower(), SeriesDto::new);
}
}

View File

@ -0,0 +1,20 @@
package de.ph87.data.location;
import lombok.NonNull;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.ListCrudRepository;
import java.util.List;
import java.util.Optional;
public interface LocationRepository extends ListCrudRepository<Location, Long> {
Optional<Location> findByName(@NonNull String name);
@Query("select new de.ph87.data.location.LocationDto(e) from Location e")
List<LocationDto> findAllDto();
@Query("select new de.ph87.data.location.LocationDto(e) from Location e where e.id = :id")
Optional<LocationDto> dtoById(long id);
}

View File

@ -0,0 +1,93 @@
package de.ph87.data.location;
import de.ph87.data.series.Series;
import de.ph87.data.series.SeriesRepository;
import jakarta.annotation.Nullable;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import static de.ph87.data.location.NotFoundException.notFound;
@Slf4j
@Service
@RequiredArgsConstructor
public class LocationService {
private final LocationRepository locationRepository;
private final SeriesRepository seriesRepository;
@NonNull
@Transactional
public LocationDto create() {
return new LocationDto(locationRepository.save(new Location()));
}
@NonNull
@Transactional
public LocationDto delete(final long id) {
return _set(id, locationRepository::delete);
}
@NonNull
@Transactional
public LocationDto name(final long id, @NonNull final String name) {
return _set(id, location -> location.setName(name));
}
@NonNull
@Transactional
public LocationDto latitude(final long id, final double latitude) {
return _set(id, location -> location.setLatitude(latitude));
}
@NonNull
@Transactional
public LocationDto longitude(final long id, final double longitude) {
return _set(id, location -> location.setLongitude(longitude));
}
@NonNull
@Transactional
public LocationDto purchase(final long id, final Long seriesId) {
return _setSeries(id, seriesId, Location::setPurchase);
}
@NonNull
@Transactional
public LocationDto delivery(final long id, final Long seriesId) {
return _setSeries(id, seriesId, Location::setDelivery);
}
@NonNull
@Transactional
public LocationDto produce(final long id, final Long seriesId) {
return _setSeries(id, seriesId, Location::setProduce);
}
@NonNull
@Transactional
public LocationDto power(final long id, final Long seriesId) {
return _setSeries(id, seriesId, Location::setPower);
}
@NonNull
private LocationDto _setSeries(final long id, @Nullable final Long seriesId, @NonNull final BiConsumer<Location, Series> setter) {
final Series series = seriesId == null ? null : seriesRepository.findById(seriesId).orElseThrow(notFound(Series.class, "id", seriesId));
return _set(id, location -> setter.accept(location, series));
}
@NonNull
private LocationDto _set(final long id, @NonNull final Consumer<Location> setter) {
final Location location = locationRepository.findById(id).orElseThrow(notFound(Location.class, "id", id));
setter.accept(location);
return new LocationDto(location);
}
}

View File

@ -0,0 +1,19 @@
package de.ph87.data.location;
import jakarta.annotation.Nullable;
import lombok.NonNull;
import java.util.function.Supplier;
public class NotFoundException extends RuntimeException {
public NotFoundException(@NonNull final Class<?> clazz, @NonNull final String key, @Nullable final Object value) {
super("Not found: %s(%s=%s)".formatted(clazz.getName(), key, String.valueOf(value)));
}
@NonNull
public static Supplier<NotFoundException> notFound(@NonNull final Class<?> clazz, @NonNull final String key, @Nullable final Object value) {
return () -> new NotFoundException(clazz, key, value);
}
}

View File

@ -1,46 +0,0 @@
package de.ph87.data.log;
import jakarta.annotation.Nullable;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.OrderColumn;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import org.slf4j.Logger;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.List;
@Getter
public abstract class AbstractEntityLog {
@NonNull
@OrderColumn
@ToString.Exclude
@ElementCollection
private List<LogMessage> log = new ArrayList<>();
public void error(@NonNull final Logger logger, @NonNull final String message) {
error(logger, message, null);
}
public void error(@NonNull final Logger logger, @NonNull final String message, @Nullable final Exception e) {
if (e instanceof RuntimeException) {
this.log.add(new LogMessage(LogSeverity.ERROR, message + "\n stacktrace:\n" + stacktraceToString(e)));
logger.error(message, e);
} else {
this.log.add(new LogMessage(LogSeverity.ERROR, message));
logger.error(message);
}
}
@NonNull
public static String stacktraceToString(@NonNull final Exception e) {
final StringWriter sw = new StringWriter();
e.printStackTrace(new PrintWriter(sw));
return sw.toString();
}
}

View File

@ -1,38 +0,0 @@
package de.ph87.data.log;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.ToString;
import java.time.ZonedDateTime;
@Getter
@ToString
@Embeddable
@NoArgsConstructor
public class LogMessage {
@NonNull
@Column(nullable = false)
private ZonedDateTime date;
@NonNull
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private LogSeverity severity;
@NonNull
@Column(nullable = false)
private String message;
public LogMessage(@NonNull final LogSeverity severity, @NonNull final String message) {
this.severity = severity;
this.message = message;
}
}

View File

@ -1,5 +0,0 @@
package de.ph87.data.log;
public enum LogSeverity {
ERROR, WARN, INFO, DEBUG
}

View File

@ -6,7 +6,7 @@ import lombok.NonNull;
import java.time.ZonedDateTime;
@Data
public class MqttInbound {
public class MqttMessage {
@NonNull
public final ZonedDateTime date = ZonedDateTime.now();
@ -17,9 +17,9 @@ public class MqttInbound {
@NonNull
public final String payload;
public MqttInbound(@NonNull final String topic, @NonNull final String payload) {
public MqttMessage(@NonNull final String topic, final org.eclipse.paho.client.mqttv3.MqttMessage message) {
this.topic = topic;
this.payload = payload;
this.payload = new String(message.getPayload());
}
}

View File

@ -1,6 +1,5 @@
package de.ph87.data.mqtt;
import de.ph87.data.topic.TopicReceiver;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
@ -11,6 +10,7 @@ import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.MqttException;
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
import org.eclipse.paho.client.mqttv3.persist.MqttDefaultFilePersistence;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import java.util.UUID;
@ -20,12 +20,12 @@ import java.util.UUID;
@RequiredArgsConstructor
public class MqttService {
private final TopicReceiver topicReceiver;
private final Object lock = new Object();
private final MqttConfig config;
private final ApplicationEventPublisher applicationEventPublisher;
private boolean stop = false;
@PostConstruct
@ -56,6 +56,9 @@ public class MqttService {
private void connectOnce() throws InterruptedException {
MqttClient client = null;
try {
if (config.getUri() == null || config.getUri().isEmpty()) {
throw new RuntimeException("MQTT-Config: URI is null or empty");
}
final boolean cleanSession = config.getClientId() == null || config.getClientId().isEmpty();
final String clientId = cleanSession ? "Data2025-TMP-" + UUID.randomUUID() : config.getClientId();
final MqttClientPersistence persistence = cleanSession ? new MemoryPersistence() : new MqttDefaultFilePersistence();
@ -67,7 +70,7 @@ public class MqttService {
options.setConnectionTimeout(5);
options.setKeepAliveInterval(2);
client.connect(options);
client.subscribe(config.getTopic(), 2, (topic, message) -> topicReceiver.receive(new MqttInbound(topic, new String(message.getPayload()))));
client.subscribe(config.getTopic(), 2, (topic, message) -> applicationEventPublisher.publishEvent(new MqttMessage(topic, message)));
log.info("MQTT connected.");
synchronized (lock) {
while (!stop && client.isConnected()) {

View File

@ -53,7 +53,7 @@ public class GraphDto {
public GraphDto(@NonNull final Graph graph) {
this.id = graph.getId();
this.version = graph.getVersion();
this.series = new SeriesDto(graph.getSeries(), false);
this.series = new SeriesDto(graph.getSeries());
this.factor = graph.getFactor();
this.operation = graph.getOperation();
this.series2 = map(graph.getSeries2(), false, SeriesDto::new);

View File

@ -1,6 +1,5 @@
package de.ph87.data.series;
import de.ph87.data.log.AbstractEntityLog;
import jakarta.annotation.Nullable;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
@ -22,7 +21,7 @@ import java.time.ZonedDateTime;
@Getter
@ToString
@NoArgsConstructor
public class Series extends AbstractEntityLog {
public class Series {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@ -31,6 +30,7 @@ public class Series extends AbstractEntityLog {
@Version
private long version;
@Setter
@NonNull
@Column(nullable = false, unique = true)
private String name;
@ -38,11 +38,11 @@ public class Series extends AbstractEntityLog {
@Setter
@NonNull
@Column(nullable = false)
private String unit;
private String unit = "";
@Setter
@Column(nullable = false)
private int decimals;
private int decimals = 1;
@Column
@Nullable
@ -58,7 +58,7 @@ public class Series extends AbstractEntityLog {
@Setter
@Column(nullable = false)
private int expectedEverySeconds = 5;
private int seconds = 5;
@Setter
@NonNull
@ -66,14 +66,18 @@ public class Series extends AbstractEntityLog {
@Enumerated(EnumType.STRING)
private SeriesType type;
public Series(@NonNull final String name, @NonNull final String unit, final int decimals, final int expectedEverySeconds, @NonNull final SeriesType type) {
public Series(@NonNull final String name, @NonNull final String unit, final int decimals, final int seconds, @NonNull final SeriesType type) {
this.name = name;
this.unit = unit;
this.decimals = decimals;
this.expectedEverySeconds = expectedEverySeconds;
this.seconds = seconds;
this.type = type;
}
public Series(@NonNull final String name) {
this.name = name;
}
public void update(@NonNull final ZonedDateTime timestamp, final double value) {
if (this.last == null || this.last.isBefore(timestamp)) {
this.last = timestamp;

View File

@ -1,5 +1,10 @@
package de.ph87.data.series;
import de.ph87.data.series.point.AllSeriesPointRequest;
import de.ph87.data.series.point.AllSeriesPointResponse;
import de.ph87.data.series.point.OneSeriesPointsRequest;
import de.ph87.data.series.point.OneSeriesPointsResponse;
import de.ph87.data.series.point.SeriesPointService;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.CrossOrigin;
@ -20,11 +25,43 @@ public class SeriesController {
private final SeriesRepository seriesRepository;
private final SeriesService SeriesService;
private final SeriesService seriesService;
@GetMapping("findAll")
public List<SeriesDto> findAll() {
return seriesRepository.findAllDto();
private final SeriesPointService seriesPointService;
@PostMapping("create")
public SeriesDto create() {
return seriesService.create();
}
@NonNull
@PostMapping("{id}/name")
public SeriesDto name(@PathVariable final long id, @RequestBody @NonNull final String name) {
return seriesService.modify(id, series -> series.setName(name));
}
@NonNull
@PostMapping("{id}/unit")
public SeriesDto unit(@PathVariable final long id, @RequestBody @NonNull final String unit) {
return seriesService.modify(id, series -> series.setUnit(unit));
}
@NonNull
@PostMapping("{id}/decimals")
public SeriesDto decimals(@PathVariable final long id, @RequestBody final int decimals) {
return seriesService.modify(id, series -> series.setDecimals(decimals));
}
@NonNull
@PostMapping("{id}/seconds")
public SeriesDto seconds(@PathVariable final long id, @RequestBody final int seconds) {
return seriesService.modify(id, series -> series.setSeconds(seconds));
}
@NonNull
@PostMapping("{id}/type")
public SeriesDto type(@PathVariable final long id, @RequestBody @NonNull final SeriesType type) {
return seriesService.modify(id, series -> series.setType(type));
}
@GetMapping("{id}")
@ -32,15 +69,20 @@ public class SeriesController {
return seriesRepository.getDtoById(id);
}
@GetMapping("findAll")
public List<SeriesDto> findAll() {
return seriesRepository.findAllDto();
}
@NonNull
@PostMapping("oneSeriesPoints")
public OneSeriesPointsResponse oneSeriesPoints(@NonNull @RequestBody final OneSeriesPointsRequest request) {
return SeriesService.oneSeriesPoints(request);
return seriesPointService.oneSeriesPoints(request);
}
@PostMapping("allSeriesPoint")
public AllSeriesPointResponse allSeriesPoint(@NonNull @RequestBody final AllSeriesPointRequest request) {
return SeriesService.allSeriesPoint(request);
return seriesPointService.allSeriesPoint(request);
}
}

View File

@ -32,11 +32,15 @@ public class SeriesDto implements IWebsocketMessage {
@Nullable
public final Double value;
public final int expectedEverySeconds;
public final int seconds;
@NonNull
public final SeriesType type;
public SeriesDto(@NonNull final Series series) {
this(series, false);
}
public SeriesDto(@NonNull final Series series, final boolean deleted) {
this.id = series.getId();
this.version = series.getVersion();
@ -47,7 +51,7 @@ public class SeriesDto implements IWebsocketMessage {
this.first = series.getFirst();
this.last = series.getLast();
this.value = series.getValue();
this.expectedEverySeconds = series.getExpectedEverySeconds();
this.seconds = series.getSeconds();
this.type = series.getType();
}

View File

@ -22,4 +22,6 @@ public interface SeriesRepository extends ListCrudRepository<Series, Long> {
Optional<Series> findFirstByOrderByNameAsc();
boolean existsByName(@NonNull String name);
}

View File

@ -1,17 +1,15 @@
package de.ph87.data.series;
import de.ph87.data.series.data.bool.BoolService;
import de.ph87.data.series.data.delta.DeltaService;
import de.ph87.data.series.data.varying.VaryingService;
import jakarta.annotation.Nullable;
import de.ph87.data.common.CrudAction;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.function.Consumer;
import static de.ph87.data.location.NotFoundException.notFound;
@Slf4j
@Service
@ -20,49 +18,42 @@ public class SeriesService {
private final SeriesRepository seriesRepository;
private final BoolService boolService;
private final DeltaService deltaService;
private final VaryingService varyingService;
@NonNull
@Transactional
public SeriesDto create() {
final String name = _generateUniqueName();
return publish(seriesRepository.save(new Series(name)), CrudAction.CREATED);
}
@NonNull
public OneSeriesPointsResponse oneSeriesPoints(@NonNull final OneSeriesPointsRequest request) {
final Series series1 = seriesRepository.findById(request.id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
final List<? extends SeriesPoint<?>> points1 = getSeriesPoints(series1, request, request.factor);
if (request.id2 != null) {
final Series series2 = seriesRepository.findById(request.id2).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
final List<? extends SeriesPoint<?>> points2 = getSeriesPoints(series2, request, request.factor2);
return new OneSeriesPointsResponse(SeriesPoint.combine(points1, points2, request.operation));
private String _generateUniqueName() {
int index = 0;
while (true) {
final String name = "series" + index;
if (!seriesRepository.existsByName(name)) {
return name;
}
}
return new OneSeriesPointsResponse(points1);
}
@NonNull
public AllSeriesPointResponse allSeriesPoint(@NonNull final AllSeriesPointRequest request) {
final List<AllSeriesPointResponse.Entry> seriesPoints = seriesRepository.findAll().stream().map(series -> map(series, request)).toList();
return new AllSeriesPointResponse(request, seriesPoints);
@Transactional
SeriesDto modify(final long id, @NonNull Consumer<Series> modifier) {
final Series series = getById(id);
modifier.accept(series);
return publish(series, CrudAction.MODIFIED);
}
@NonNull
private AllSeriesPointResponse.Entry map(@NonNull final Series series, @NonNull final ISeriesPointRequest request) {
final List<? extends SeriesPoint<?>> points = getSeriesPoints(series, request, null);
final SeriesDto seriesDto = new SeriesDto(series, false);
final SeriesPoint<?> point = points.isEmpty() ? null : points.getFirst();
return new AllSeriesPointResponse.Entry(seriesDto, point);
private Series getById(final long id) {
return seriesRepository.findById(id).orElseThrow(notFound(Series.class, "id", id));
}
@NonNull
private List<? extends SeriesPoint<?>> getSeriesPoints(@NonNull final Series series, @NonNull final ISeriesPointRequest request, @Nullable final Double factor) {
final List<? extends SeriesPoint<?>> points = switch (series.getType()) {
case BOOL -> boolService.points(series, request);
case DELTA -> deltaService.points(series, request);
case VARYING -> varyingService.points(series, request);
};
if (factor == null || factor == 1) {
return points;
}
return points.stream().map(p -> p.times(factor)).toList();
private SeriesDto publish(@NonNull final Series series, @NonNull final CrudAction action) {
final SeriesDto dto = new SeriesDto(series);
log.info("{} {}: {}", Series.class.getSimpleName(), action, dto);
return dto;
}
}

View File

@ -24,7 +24,7 @@ public class BoolDto implements IWebsocketMessage {
public final boolean terminated;
public BoolDto(@NonNull final Bool bool) {
this.series = new SeriesDto(bool.getId().getSeries(), false);
this.series = new SeriesDto(bool.getId().getSeries());
this.date = bool.getId().getDate();
this.end = bool.getEnd();
this.state = bool.isState();

View File

@ -2,7 +2,7 @@ package de.ph87.data.series.data.bool;
import de.ph87.data.plot.axis.graph.GraphDivisionByZero;
import de.ph87.data.plot.axis.graph.GraphOperation;
import de.ph87.data.series.SeriesPoint;
import de.ph87.data.series.point.SeriesPoint;
import lombok.Data;
import lombok.NonNull;
import tools.jackson.core.JsonGenerator;

View File

@ -1,6 +1,6 @@
package de.ph87.data.series.data.bool;
import de.ph87.data.series.ISeriesPointRequest;
import de.ph87.data.series.point.ISeriesPointRequest;
import de.ph87.data.series.Series;
import de.ph87.data.series.data.DataId;
import lombok.NonNull;
@ -38,15 +38,15 @@ public class BoolService {
.peek(
existing -> {
if (existing.isState() != state) {
id.getSeries().error(log, "Differing states: received=(begin=%s, end=%s, state=%s, terminated=%s), existing=%s".formatted(begin, end, state, terminated, existing));
log.error("Differing states: received=(begin={}, end={}, state={}, terminated={}), existing={}", begin, end, state, terminated, existing);
return;
}
if (existing.getEnd().isAfter(end)) {
id.getSeries().error(log, "End ran backwards: received=(begin=%s, end=%s, state=%s, terminated=%s), existing=%s".formatted(begin, end, state, terminated, existing));
log.error("End ran backwards: received=(begin={}, end={}, state={}, terminated={}), existing={}", begin, end, state, terminated, existing);
return;
}
if (existing.isTerminated() && (!terminated || !existing.getEnd().equals(end))) {
id.getSeries().error(log, "Already terminated: received=(begin=%s, end=%s, state=%s, terminated=%s), existing=%s".formatted(begin, end, state, terminated, existing));
log.error("Already terminated: received=(begin={}, end={}, state={}, terminated={}), existing={}", begin, end, state, terminated, existing);
return;
}
existing.setEnd(end);

View File

@ -2,7 +2,7 @@ package de.ph87.data.series.data.delta;
import de.ph87.data.plot.axis.graph.GraphDivisionByZero;
import de.ph87.data.plot.axis.graph.GraphOperation;
import de.ph87.data.series.SeriesPoint;
import de.ph87.data.series.point.SeriesPoint;
import lombok.Data;
import lombok.NonNull;
import tools.jackson.core.JsonGenerator;

View File

@ -1,6 +1,6 @@
package de.ph87.data.series.data.delta;
import de.ph87.data.series.ISeriesPointRequest;
import de.ph87.data.series.point.ISeriesPointRequest;
import de.ph87.data.series.Series;
import de.ph87.data.series.data.Interval;
import de.ph87.data.series.data.delta.meter.Meter;

View File

@ -22,7 +22,7 @@ public class MeterDto {
public MeterDto(@NonNull final Meter meter) {
this.id = meter.getId();
this.series = new SeriesDto(meter.getSeries(), false);
this.series = new SeriesDto(meter.getSeries());
this.number = meter.getNumber();
this.first = meter.getFirst();
}

View File

@ -31,7 +31,7 @@ public abstract class VaryingDto implements IWebsocketMessage {
public final Interval interval;
protected VaryingDto(@NonNull final Varying varying, @NonNull final Interval interval) {
this.series = new SeriesDto(varying.getId().getSeries(), false);
this.series = new SeriesDto(varying.getId().getSeries());
this.date = varying.getId().getDate();
this.min = varying.getMin();
this.max = varying.getMax();

View File

@ -2,7 +2,7 @@ package de.ph87.data.series.data.varying;
import de.ph87.data.plot.axis.graph.GraphDivisionByZero;
import de.ph87.data.plot.axis.graph.GraphOperation;
import de.ph87.data.series.SeriesPoint;
import de.ph87.data.series.point.SeriesPoint;
import lombok.Data;
import lombok.NonNull;
import tools.jackson.core.JsonGenerator;

View File

@ -1,6 +1,6 @@
package de.ph87.data.series.data.varying;
import de.ph87.data.series.ISeriesPointRequest;
import de.ph87.data.series.point.ISeriesPointRequest;
import de.ph87.data.series.Series;
import de.ph87.data.series.data.DataId;
import de.ph87.data.series.data.Interval;

View File

@ -1,4 +1,4 @@
package de.ph87.data.series;
package de.ph87.data.series.point;
import com.fasterxml.jackson.annotation.JsonProperty;
import de.ph87.data.series.data.Interval;

View File

@ -1,5 +1,6 @@
package de.ph87.data.series;
package de.ph87.data.series.point;
import de.ph87.data.series.SeriesDto;
import jakarta.annotation.Nullable;
import lombok.Data;
import lombok.NonNull;

View File

@ -1,4 +1,4 @@
package de.ph87.data.series;
package de.ph87.data.series.point;
import de.ph87.data.series.data.Interval;
import lombok.NonNull;

View File

@ -1,4 +1,4 @@
package de.ph87.data.series;
package de.ph87.data.series.point;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;

View File

@ -1,4 +1,4 @@
package de.ph87.data.series;
package de.ph87.data.series.point;
import tools.jackson.databind.annotation.JsonSerialize;
import lombok.Data;

View File

@ -1,4 +1,4 @@
package de.ph87.data.series;
package de.ph87.data.series.point;
import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonGenerator;

View File

@ -1,4 +1,4 @@
package de.ph87.data.series;
package de.ph87.data.series.point;
import de.ph87.data.plot.axis.graph.GraphDivisionByZero;
import de.ph87.data.plot.axis.graph.GraphOperation;

View File

@ -0,0 +1,71 @@
package de.ph87.data.series.point;
import de.ph87.data.series.Series;
import de.ph87.data.series.SeriesDto;
import de.ph87.data.series.SeriesRepository;
import de.ph87.data.series.data.bool.BoolService;
import de.ph87.data.series.data.delta.DeltaService;
import de.ph87.data.series.data.varying.VaryingService;
import jakarta.annotation.Nullable;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class SeriesPointService {
private final SeriesRepository seriesRepository;
private final BoolService boolService;
private final DeltaService deltaService;
private final VaryingService varyingService;
@NonNull
public OneSeriesPointsResponse oneSeriesPoints(@NonNull final OneSeriesPointsRequest request) {
final Series series1 = seriesRepository.findById(request.id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
final List<? extends SeriesPoint<?>> points1 = getSeriesPoints(series1, request, request.factor);
if (request.id2 != null) {
final Series series2 = seriesRepository.findById(request.id2).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
final List<? extends SeriesPoint<?>> points2 = getSeriesPoints(series2, request, request.factor2);
return new OneSeriesPointsResponse(SeriesPoint.combine(points1, points2, request.operation));
}
return new OneSeriesPointsResponse(points1);
}
@NonNull
public AllSeriesPointResponse allSeriesPoint(@NonNull final AllSeriesPointRequest request) {
final List<AllSeriesPointResponse.Entry> seriesPoints = seriesRepository.findAll().stream().map(series -> map(series, request)).toList();
return new AllSeriesPointResponse(request, seriesPoints);
}
@NonNull
private AllSeriesPointResponse.Entry map(@NonNull final Series series, @NonNull final ISeriesPointRequest request) {
final List<? extends SeriesPoint<?>> points = getSeriesPoints(series, request, null);
final SeriesDto seriesDto = new SeriesDto(series);
final SeriesPoint<?> point = points.isEmpty() ? null : points.getFirst();
return new AllSeriesPointResponse.Entry(seriesDto, point);
}
@NonNull
private List<? extends SeriesPoint<?>> getSeriesPoints(@NonNull final Series series, @NonNull final ISeriesPointRequest request, @Nullable final Double factor) {
final List<? extends SeriesPoint<?>> points = switch (series.getType()) {
case BOOL -> boolService.points(series, request);
case DELTA -> deltaService.points(series, request);
case VARYING -> varyingService.points(series, request);
};
if (factor == null || factor == 1) {
return points;
}
return points.stream().map(p -> p.times(factor)).toList();
}
}

View File

@ -1,7 +0,0 @@
package de.ph87.data.topic;
public enum TimestampType {
EPOCH_MILLISECONDS,
EPOCH_SECONDS,
ISO_LOCAL_DATE_TIME,
}

View File

@ -1,34 +1,21 @@
package de.ph87.data.topic;
import de.ph87.data.log.AbstractEntityLog;
import de.ph87.data.topic.query.TopicQuery;
import jakarta.annotation.Nullable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Version;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.Setter;
import lombok.ToString;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@ToString
@NoArgsConstructor
public class Topic extends AbstractEntityLog {
public class Topic {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@ -41,76 +28,4 @@ public class Topic extends AbstractEntityLog {
@Column(nullable = false, unique = true)
private String name;
@Setter
@Column(nullable = false)
private boolean enabled = true;
@NonNull
@Column(nullable = false)
private ZonedDateTime first;
@NonNull
@Column(nullable = false)
private ZonedDateTime last;
@Column(nullable = false)
private int count;
@Setter
@NonNull
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private TimestampType timestampType = TimestampType.EPOCH_SECONDS;
@Setter
@Column
@Nullable
private ZonedDateTime timestampLast = null;
@Setter
@NonNull
@Column(nullable = false)
private String timestampQuery = "";
@Setter
@NonNull
@Column(nullable = false)
private String meterNumberQuery = "";
@Setter
@NonNull
@Column(nullable = false)
private String meterNumberLast = "";
@NonNull
@ToString.Exclude
@ElementCollection(fetch = FetchType.EAGER)
private List<TopicQuery> queries = new ArrayList<>();
@Lob
@Setter
@NonNull
@ToString.Exclude
@Column(nullable = false)
private String error = "";
@Lob
@NonNull
@ToString.Exclude
@Column(nullable = false)
private String payload = "";
public Topic(@NonNull final String name) {
this.name = name;
this.first = ZonedDateTime.now();
this.last = this.first;
this.count = 1;
}
public void update(@NonNull final String payload) {
this.last = ZonedDateTime.now();
this.payload = payload;
this.count++;
}
}

View File

@ -1,45 +0,0 @@
package de.ph87.data.topic;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@CrossOrigin
@RestController
@RequiredArgsConstructor
@RequestMapping("Topic")
public class TopicController {
private final TopicService topicService;
private final TopicRepository topicRepository;
@GetMapping("findAll")
public List<TopicDto> findAll() {
return topicRepository.findAllDto();
}
@PostMapping("{id}/setEnabled")
public TopicDto setEnabled(@PathVariable final long id, @RequestBody final boolean enabled) {
return topicService.setEnabled(id, enabled);
}
@PostMapping("{id}/setTimestampQuery")
public TopicDto setTimestampQuery(@PathVariable final long id, @NonNull @RequestBody final String timestampQuery) {
return topicService.setTimestampQuery(id, timestampQuery);
}
@PostMapping("{id}/setTimestampType")
public TopicDto setTimestampType(@PathVariable final long id, @NonNull @RequestBody final String timestampType) {
return topicService.setTimestampType(id, TimestampType.valueOf(timestampType));
}
}

View File

@ -1,14 +1,9 @@
package de.ph87.data.topic;
import de.ph87.data.topic.query.TopicQueryDto;
import de.ph87.data.websocket.IWebsocketMessage;
import jakarta.annotation.Nullable;
import lombok.Data;
import lombok.NonNull;
import java.time.ZonedDateTime;
import java.util.List;
@Data
public class TopicDto implements IWebsocketMessage {
@ -17,55 +12,9 @@ public class TopicDto implements IWebsocketMessage {
@NonNull
public final String name;
@NonNull
public final ZonedDateTime first;
@NonNull
public final ZonedDateTime last;
public final long count;
public final boolean enabled;
@NonNull
public final TimestampType timestampType;
@NonNull
public final String timestampQuery;
@Nullable
public final ZonedDateTime timestampLast;
@NonNull
public final String meterNumberQuery;
@Nullable
public final String meterNumberLast;
@NonNull
public final List<TopicQueryDto> queries;
@NonNull
public final String error;
@NonNull
public final String payload;
public TopicDto(@NonNull final Topic topic) {
this.id = topic.getId();
this.name = topic.getName();
this.first = topic.getFirst();
this.last = topic.getLast();
this.count = topic.getCount();
this.enabled = topic.isEnabled();
this.timestampType = topic.getTimestampType();
this.timestampQuery = topic.getTimestampQuery();
this.timestampLast = topic.getTimestampLast();
this.meterNumberQuery = topic.getMeterNumberQuery();
this.meterNumberLast = topic.getMeterNumberLast();
this.queries = topic.getQueries().stream().map(TopicQueryDto::new).toList();
this.error = topic.getError();
this.payload = topic.getPayload();
}
}

View File

@ -1,206 +1,20 @@
package de.ph87.data.topic;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import de.ph87.data.mqtt.MqttInbound;
import de.ph87.data.series.Series;
import de.ph87.data.series.SeriesDto;
import de.ph87.data.series.SeriesType;
import de.ph87.data.series.data.bool.BoolService;
import de.ph87.data.series.data.delta.DeltaService;
import de.ph87.data.series.data.varying.VaryingService;
import de.ph87.data.topic.query.TopicQuery;
import de.ph87.data.mqtt.MqttMessage;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
public class TopicReceiver {
public static final String SML_METER_NUMBER_RAW = "sml_meter_number_raw:";
@EventListener(MqttMessage.class)
public void receive(@NonNull final MqttMessage mqttMessage) {
private final TopicRepository topicRepository;
private final BoolService boolService;
private final DeltaService deltaService;
private final VaryingService varyingService;
private final ApplicationEventPublisher applicationEventPublisher;
private final TopicService topicService;
@Transactional
public void receive(@NonNull final MqttInbound inbound) {
final Topic topic = updateOrCreate(inbound.topic, inbound.payload);
try {
if (!topic.isEnabled()) {
log.debug("Topic is not enabled: topic={}", topic);
return;
}
if (topic.getTimestampQuery().isEmpty()) {
log.debug("Topic timestampQuery is not set: topic={}", topic);
return;
}
if (topic.getQueries().isEmpty()) {
log.debug("Topic queries not set: topic={}", topic);
return;
}
log.debug("Parsing Topic payload: topic={}", topic);
final DocumentContext json;
try {
json = JsonPath.parse(inbound.payload);
} catch (Exception e) {
topic.error(log, "Error parsing JSON: %s\n topic=%s\n inbound=%s".formatted(e.toString(), topic, inbound), e);
return;
}
log.debug("Executing Topic timestampQuery: topic={}", topic);
final ZonedDateTime date;
try {
date = queryTimestamp(json, topic.getTimestampQuery(), topic.getTimestampType());
} catch (Exception e) {
topic.error(log, "Error executing Topic timestampQuery: %s\n topic=%s\n inbound=%s".formatted(e.toString(), topic, inbound), e);
return;
}
topic.setTimestampLast(date);
topic.getQueries().forEach(query -> query(topic, inbound, json, date, query));
} finally {
topicService.publish(topic);
}
}
private void query(@NonNull final Topic topic, @NonNull final MqttInbound inbound, @NonNull final DocumentContext json, @NonNull final ZonedDateTime date, @NonNull final TopicQuery query) {
log.debug("Executing TopicQuery: topicQuery={}", query);
try {
final Series series = query.getSeries();
if (series == null) {
log.debug("TopicQuery Series not set: topic={}", topic);
return;
}
if (query.getValueQuery().isEmpty()) {
log.debug("TopicQuery valueQuery not set: topic={}", topic);
return;
}
if (series.getType() == SeriesType.BOOL) {
if (query.getBeginQuery().isEmpty()) {
log.debug("TopicQuery beginQuery not set: topic={}", topic);
return;
}
if (query.getTerminatedQuery().isEmpty()) {
log.debug("TopicQuery terminatedQuery not set: topic={}", topic);
return;
}
}
if (series.getType() == SeriesType.DELTA) {
if (topic.getMeterNumberQuery().isEmpty()) {
log.debug("TopicQuery meterNumberQuery not set: topic={}", topic);
return;
}
}
final Object valueRaw = json.read(query.getValueQuery());
queryValue(valueRaw).ifPresentOrElse(v -> {
final double value = query.getFunction().apply(v) * query.getFactor();
series.update(date, value);
applicationEventPublisher.publishEvent(new SeriesDto(series, false));
switch (series.getType()) {
case BOOL -> {
final ZonedDateTime begin = queryTimestamp(json, query.getBeginQuery(), topic.getTimestampType());
final boolean terminated = queryBoolean(json, query.getTerminatedQuery());
boolService.write(series, begin, date, value > 0, terminated);
}
case DELTA -> {
final String meterNumber = queryMeterNumber(topic, json);
topic.setMeterNumberLast(meterNumber);
deltaService.write(series, meterNumber, date, value);
}
case VARYING -> varyingService.write(series, date, value);
}
}, () -> topic.error(log, "Failed to parse value: %s".formatted(valueRaw)));
} catch (Exception e) {
topic.error(log, "Error executing TopicQuery: %s\n topic=%s\n query=%s\n inbound=%s".formatted(e.toString(), topic, query, inbound), e);
}
}
@NonNull
private static String queryMeterNumber(@NonNull final Topic topic, @NonNull final DocumentContext json) {
final String query = topic.getMeterNumberQuery();
if (query.startsWith(SML_METER_NUMBER_RAW)) {
final String field = query.substring(SML_METER_NUMBER_RAW.length());
final String raw = json.read(field, String.class);
if (raw.isEmpty()) {
throw new NumberFormatException("Cannot parse Meter number: No Hex-chars read.");
}
if (raw.length() % 2 != 0) {
throw new NumberFormatException("Cannot parse Meter number: Hex-char count must be multiple of 2.");
}
final int length = Integer.parseInt(raw.substring(0, 2), 16);
if (raw.length() != length * 2) {
throw new NumberFormatException("Cannot parse Meter number: Invalid length");
}
final int type = Integer.parseInt(raw.substring(2, 4), 16);
final String name = "" + (char) Integer.parseInt(raw.substring(4, 6), 16) + (char) Integer.parseInt(raw.substring(6, 8), 16) + (char) Integer.parseInt(raw.substring(8, 10), 16);
final int number = Integer.parseInt(raw.substring(10), 16);
return "%d%s%s".formatted(type, name, number);
} else if (query.startsWith("\"") && query.endsWith("\"")) {
return query.substring(1, query.length() - 1);
}
return json.read(query, String.class);
}
private static boolean queryBoolean(@NonNull final DocumentContext json, @NonNull final String terminatedQuery) {
if ("true".equals(terminatedQuery)) {
return true;
}
if ("false".equals(terminatedQuery)) {
return false;
}
return json.read(terminatedQuery, Boolean.class);
}
private static Optional<Double> queryValue(final Object valueRaw) {
if (valueRaw instanceof final Double n) {
return Optional.of(n);
} else if (valueRaw instanceof final Integer n) {
return Optional.of((double) n);
} else if (valueRaw instanceof final Long n) {
return Optional.of((double) n);
} else if (valueRaw instanceof final Boolean b) {
return Optional.of(b ? 1.0 : 0.0);
}
return Optional.empty();
}
@NonNull
private Topic updateOrCreate(@NonNull final String name, @NonNull final String payload) {
return topicRepository.findByName(name).stream().peek(topic -> topic.update(payload)).findFirst().orElseGet(() -> topicRepository.save(new Topic(name)));
}
@NonNull
private static ZonedDateTime queryTimestamp(@NonNull final DocumentContext json, @NonNull final String query, @NonNull final TimestampType type) {
if ("now".equals(query) || "timestamp".equals(query)) {
return ZonedDateTime.now();
}
return switch (type) {
case TimestampType.EPOCH_SECONDS -> ZonedDateTime.ofInstant(Instant.ofEpochSecond(json.read(query, Long.class)), ZoneId.systemDefault());
case TimestampType.EPOCH_MILLISECONDS -> ZonedDateTime.ofInstant(Instant.ofEpochMilli(json.read(query, Long.class)), ZoneId.systemDefault());
case TimestampType.ISO_LOCAL_DATE_TIME -> ZonedDateTime.of(LocalDateTime.parse(json.read(query, String.class)), ZoneId.systemDefault());
};
}
}

View File

@ -1,51 +0,0 @@
package de.ph87.data.topic;
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.util.function.Consumer;
@Slf4j
@Service
@RequiredArgsConstructor
public class TopicService {
private final TopicRepository topicRepository;
private final ApplicationEventPublisher applicationEventPublisher;
@Transactional
public TopicDto setEnabled(final long id, final boolean enabled) {
return set(id, t -> t.setEnabled(enabled));
}
@Transactional
public TopicDto setTimestampQuery(final long id, @NonNull final String timestampQuery) {
return set(id, t -> t.setTimestampQuery(timestampQuery));
}
@Transactional
public TopicDto setTimestampType(final long id, @NonNull final TimestampType timestampType) {
return set(id, t -> t.setTimestampType(timestampType));
}
@NonNull
private TopicDto set(final long id, @NonNull final Consumer<Topic> modifier) {
final Topic topic = topicRepository.findById(id).orElseThrow();
modifier.accept(topic);
log.info("Topic CHANGED: {}", topic);
return publish(topic);
}
@NonNull
public TopicDto publish(@NonNull final Topic topic) {
final TopicDto dto = new TopicDto(topic);
applicationEventPublisher.publishEvent(dto);
return dto;
}
}

View File

@ -1,112 +0,0 @@
package de.ph87.data.topic.query;
import de.ph87.data.series.Series;
import jakarta.annotation.Nullable;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.ManyToOne;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.ToString;
@Getter
@ToString
@Embeddable
@NoArgsConstructor
public class TopicQuery {
@Nullable
@ManyToOne
private Series series;
@NonNull
@Column(nullable = false)
private String valueQuery = "";
@NonNull
@Column(nullable = false)
private String beginQuery = "";
@NonNull
@Column(nullable = false)
private String terminatedQuery = "";
@NonNull
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private TopicQuery.Function function = Function.NONE;
@Column(nullable = false)
private double factor;
public TopicQuery(
@Nullable final Series series,
@NonNull final String valueQuery
) {
this(series, valueQuery, "", "");
}
public TopicQuery(
@Nullable final Series series,
@NonNull final String valueQuery,
final double factor
) {
this(series, valueQuery, factor, Function.NONE, "", "");
}
public TopicQuery(
@Nullable final Series series,
@NonNull final String valueQuery,
final double factor,
@NonNull final TopicQuery.Function function
) {
this(series, valueQuery, factor, function, "", "");
}
public TopicQuery(
@Nullable final Series series,
@NonNull final String valueQuery,
@NonNull final String beginQuery,
@NonNull final String terminatedQuery
) {
this(series, valueQuery, 1, Function.NONE, beginQuery, terminatedQuery);
}
public TopicQuery(
@Nullable final Series series,
@NonNull final String valueQuery,
final double factor,
@NonNull final TopicQuery.Function function,
@NonNull final String beginQuery,
@NonNull final String terminatedQuery
) {
this.series = series;
this.valueQuery = valueQuery;
this.beginQuery = beginQuery;
this.terminatedQuery = terminatedQuery;
this.function = function;
this.factor = factor;
}
public enum Function {
NONE(v -> v),
ONLY_POSITIVE(v -> v > 0 ? v : 0),
ONLY_NEGATIVE_BUT_NEGATE(v -> v < 0 ? -v : 0),
;
private final java.util.function.Function<Double, Double> function;
Function(@NonNull java.util.function.Function<Double, Double> function) {
this.function = function;
}
public double apply(final double value) {
return function.apply(value);
}
}
}

View File

@ -1,41 +0,0 @@
package de.ph87.data.topic.query;
import de.ph87.data.series.SeriesDto;
import jakarta.annotation.Nullable;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import static de.ph87.data.Helpers.map;
@Getter
@ToString
public class TopicQueryDto {
@Nullable
public final SeriesDto series;
@NonNull
public final String valueQuery;
@NonNull
public final String beginQuery;
@NonNull
public final String terminatedQuery;
@NonNull
public final TopicQuery.Function function;
public final double factor;
public TopicQueryDto(@NonNull final TopicQuery topicQuery) {
this.series = map(topicQuery.getSeries(), series -> new SeriesDto(series, false));
this.valueQuery = topicQuery.getValueQuery();
this.beginQuery = topicQuery.getBeginQuery();
this.terminatedQuery = topicQuery.getTerminatedQuery();
this.function = topicQuery.getFunction();
this.factor = topicQuery.getFactor();
}
}