Compare commits

...

10 Commits

115 changed files with 17037 additions and 121 deletions

View File

@ -0,0 +1,18 @@
# 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
[*.ts]
# noinspection EditorConfigKeyCorrectness
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

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

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
{
"name": "angular",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^18.2.0",
"@angular/common": "^18.2.0",
"@angular/compiler": "^18.2.0",
"@angular/core": "^18.2.0",
"@angular/forms": "^18.2.0",
"@angular/platform-browser": "^18.2.0",
"@angular/platform-browser-dynamic": "^18.2.0",
"@angular/router": "^18.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.14.10",
"@stomp/ng2-stompjs": "^8.0.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^18.2.11",
"@angular/cli": "^18.2.11",
"@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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<style type="text/css">
.fill {
fill: #bc1c1c;
}
.stroke {
fill: none;
stroke: #FFFFFF;
stroke-width: 4;
stroke-linecap: round;
stroke-miterlimit: 10;
}
</style>
<circle class="fill" cx="32" cy="32" r="32"/>
<path d="M32,52c-9.9,0-18-8.1-18-18c0-6.4,3.4-12.3,8.9-15.5c1-0.6,2.2-0.2,2.7,0.7c0.6,1,0.2,2.2-0.7,2.7
C20.7,24.4,18,29.1,18,34c0,7.7,6.3,14,14,14c7.7,0,14-6.3,14-14c0-5.1-2.7-9.7-7.2-12.2c-1-0.5-1.3-1.8-0.8-2.7
c0.5-1,1.8-1.3,2.7-0.8C46.5,21.5,50,27.5,50,34C50,43.9,41.9,52,32,52z"/>
<path d="M32,36c-1.1,0-2-0.9-2-2V14c0-1.1,0.9-2,2-2c1.1,0,2,0.9,2,2v20C34,35.1,33.1,36,32,36z"/>
<path class="stroke" d="M39.8,18c4.9,2.7,8.2,8,8.2,14c0,8.8-7.2,16-16,16c-8.8,0-16-7.2-16-16c0-5.9,3.2-11,7.9-13.8"/>
<line class="stroke" x1="32" y1="32" x2="32" y2="12"/>
</svg>

After

Width:  |  Height:  |  Size: 944 B

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<style type="text/css">
.fill {
fill: #5cc75e;
}
.stroke {
fill: none;
stroke: #FFFFFF;
stroke-width: 4;
stroke-linecap: round;
stroke-miterlimit: 10;
}
</style>
<circle class="fill" cx="32" cy="32" r="32"/>
<path d="M32,52c-9.9,0-18-8.1-18-18c0-6.4,3.4-12.3,8.9-15.5c1-0.6,2.2-0.2,2.7,0.7c0.6,1,0.2,2.2-0.7,2.7
C20.7,24.4,18,29.1,18,34c0,7.7,6.3,14,14,14c7.7,0,14-6.3,14-14c0-5.1-2.7-9.7-7.2-12.2c-1-0.5-1.3-1.8-0.8-2.7
c0.5-1,1.8-1.3,2.7-0.8C46.5,21.5,50,27.5,50,34C50,43.9,41.9,52,32,52z"/>
<path d="M32,36c-1.1,0-2-0.9-2-2V14c0-1.1,0.9-2,2-2c1.1,0,2,0.9,2,2v20C34,35.1,33.1,36,32,36z"/>
<path class="stroke" d="M39.8,18c4.9,2.7,8.2,8,8.2,14c0,8.8-7.2,16-16,16c-8.8,0-16-7.2-16-16c0-5.9,3.2-11,7.9-13.8"/>
<line class="stroke" x1="32" y1="32" x2="32" y2="12"/>
</svg>

After

Width:  |  Height:  |  Size: 944 B

View File

@ -0,0 +1,30 @@
import {Property} from "../Property/Property";
import {orNull, validateString} from "../common/validators";
export class Device {
constructor(
readonly uuid: string,
readonly name: string,
readonly slug: string,
readonly statePropertyId: string,
readonly stateProperty: Property | null,
) {
// -
}
static fromJson(json: any): Device {
return new Device(
validateString(json.uuid),
validateString(json.name),
validateString(json.slug),
validateString(json.statePropertyId),
orNull(json.stateProperty, Property.fromJson),
);
}
static trackBy(index: number, device: Device) {
return device.uuid;
}
}

View File

@ -0,0 +1,5 @@
export class DeviceFilter {
search: string = "";
}

View File

@ -0,0 +1,32 @@
import {Injectable} from '@angular/core';
import {CrudService} from '../common/CrudService';
import {Device} from './Device';
import {ApiService} from '../common/api.service';
import {Next} from '../common/types';
import {DeviceFilter} from './DeviceFilter';
@Injectable({
providedIn: 'root'
})
export class DeviceService extends CrudService<Device> {
constructor(
api: ApiService,
) {
super(api, ['Device'], Device.fromJson);
}
getByUuid(uuid: string, next: Next<Device>): void {
this.getSingle(['getByUuid', uuid], next);
}
list(filter: DeviceFilter | null, next: Next<Device[]>): void {
this.postList(['list'], filter, next);
}
setState(device: Device, state: boolean, next?: Next<void>): void {
this.getNone(['setState', device.uuid, state], next);
}
}

View File

@ -0,0 +1,33 @@
import {orNull, validateDateOrNull, validateString} from '../common/validators';
import {State} from '../State/State';
export class Group {
constructor(
readonly address: string,
readonly name: string,
readonly description: string,
readonly dpt: string,
readonly state: State | null,
readonly lastValueChange: Date | null,
) {
// -
}
static fromJson(json: any): Group {
return new Group(
validateString(json.address),
validateString(json.name),
validateString(json.description),
validateString(json.dpt),
orNull(json.state, State.fromJson),
validateDateOrNull(json.lastValueChange),
);
}
static trackBy(index: number, group: Group) {
return group.address;
}
}

View File

@ -0,0 +1,5 @@
export class GroupFilter {
search: string = "";
}

View File

@ -0,0 +1,28 @@
import {Injectable} from '@angular/core';
import {CrudService} from '../common/CrudService';
import {Group} from './Group';
import {ApiService} from '../common/api.service';
import {Next} from '../common/types';
import {GroupFilter} from './GroupFilter';
@Injectable({
providedIn: 'root'
})
export class GroupService extends CrudService<Group> {
constructor(
api: ApiService,
) {
super(api, ['Knx', 'Group'], Group.fromJson);
}
getByAddress(address: string, next: Next<Group>): void {
this.getSingle(['getByAddress', address], next);
}
list(filter: GroupFilter | null, next: Next<Group[]>): void {
this.postList(['list'], filter, next);
}
}

View File

@ -0,0 +1,24 @@
import {State} from "../State/State";
import {orNull, validateDateOrNull, validateString} from "../common/validators";
export class Property {
constructor(
readonly id: string,
readonly type: string,
readonly state: State | null,
readonly lastValueChange: Date | null,
) {
// -
}
static fromJson(json: any): Property {
return new Property(
validateString(json.id),
validateString(json.type),
orNull(json.state, State.fromJson),
validateDateOrNull(json.lastValueChange),
);
}
}

View File

@ -0,0 +1,30 @@
import {Property} from "../Property/Property";
import {orNull, validateString} from "../common/validators";
export class Shutter {
constructor(
readonly uuid: string,
readonly name: string,
readonly slug: string,
readonly positionPropertyId: string,
readonly positionProperty: Property | null,
) {
// -
}
static fromJson(json: any): Shutter {
return new Shutter(
validateString(json.uuid),
validateString(json.name),
validateString(json.slug),
validateString(json.positionPropertyId),
orNull(json.positionProperty, Property.fromJson),
);
}
static trackBy(index: number, shutter: Shutter) {
return shutter.uuid;
}
}

View File

@ -0,0 +1,5 @@
export class ShutterFilter {
search: string = "";
}

View File

@ -0,0 +1,32 @@
import {Injectable} from '@angular/core';
import {CrudService} from '../common/CrudService';
import {Shutter} from './Shutter';
import {ApiService} from '../common/api.service';
import {Next} from '../common/types';
import {ShutterFilter} from './ShutterFilter';
@Injectable({
providedIn: 'root'
})
export class ShutterService extends CrudService<Shutter> {
constructor(
api: ApiService,
) {
super(api, ['Shutter'], Shutter.fromJson);
}
getByUuid(uuid: string, next: Next<Shutter>): void {
this.getSingle(['getByUuid', uuid], next);
}
list(filter: ShutterFilter | null, next: Next<Shutter[]>): void {
this.postList(['list'], filter, next);
}
setPosition(shutter: Shutter, position: number, next?: Next<void>): void {
this.getNone(['setPosition', shutter.uuid, position], next);
}
}

View File

@ -0,0 +1,23 @@
import {validateDate, validateString} from "../common/validators";
export class State {
constructor(
readonly type: string,
readonly timestamp: Date,
readonly value: any,
readonly string: string,
) {
// -
}
static fromJson(json: any): State {
return new State(
validateString(json.type),
validateDate(json.timestamp),
json.value,
validateString(json.string),
);
}
}

View File

@ -0,0 +1,4 @@
export enum CrudDirection {
ASC = 'ASC',
DESC = 'DESC',
}

View File

@ -0,0 +1,52 @@
import {ApiService} from "./api.service";
import {FromJson, Next} from "./types";
import {Page} from "./Page";
import {Subscription} from "rxjs";
export abstract class CrudService<ENTITY> {
protected constructor(
readonly api: ApiService,
readonly path: any[],
readonly fromJson: FromJson<ENTITY>,
) {
// -
}
subscribe(next: Next<ENTITY>): Subscription {
return this.api.subscribe([...this.path], this.fromJson, next);
}
protected getNone(path: any[], next?: Next<void>): void {
this.api.getNone([...this.path, ...path], next);
}
protected getSingle(path: any[], next?: Next<ENTITY>): void {
this.api.getSingle([...this.path, ...path], this.fromJson, next);
}
protected getList(path: any[], next?: Next<ENTITY[]>): void {
this.api.getList([...this.path, ...path], this.fromJson, next);
}
protected getPage(path: any[], next?: Next<Page<ENTITY>>): void {
this.api.getPage([...this.path, ...path], this.fromJson, next);
}
protected postNone(path: any[], data: any, next?: Next<void>): void {
this.api.postNone([...this.path, ...path], data, next);
}
protected postSingle(path: any[], data: any, next?: Next<ENTITY>): void {
this.api.postSingle([...this.path, ...path], data, this.fromJson, next);
}
protected postList(path: any[], data: any, next?: Next<ENTITY[]>): void {
this.api.postList([...this.path, ...path], data, this.fromJson, next);
}
protected postPage(path: any[], data: any, next?: Next<Page<ENTITY>>): void {
this.api.postPage([...this.path, ...path], data, this.fromJson, next);
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,79 @@
import {Injectable} from '@angular/core';
import {HttpClient} from "@angular/common/http";
import {map, Subscription} from "rxjs";
import {FromJson, getApiUrl, 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>): Subscription {
return this.http.get<void>(getApiUrl('http', path), {withCredentials: true}).subscribe(next);
}
getString(path: any[], next: Next<string>): Subscription {
return this.http.get(getApiUrl('http', path), {responseType: "text", withCredentials: true}).subscribe(next);
}
getSingle<T>(path: any[], fromJson: FromJson<T>, next?: Next<T>): Subscription {
return this.http.get<any>(getApiUrl('http', path), {withCredentials: true}).pipe(map(fromJson)).subscribe(next);
}
getPage<T>(path: any[], fromJson: FromJson<T>, next: Next<Page<T>> | undefined = undefined): Subscription {
return this.http.get<any>(getApiUrl('http', path), {withCredentials: true}).pipe(map(Page.fromJson(fromJson))).subscribe(next);
}
getList<T>(path: any[], fromJson: FromJson<T>, next: Next<T[]> | undefined = undefined): Subscription {
return this.http.get<any[]>(getApiUrl('http', path), {withCredentials: true}).pipe(map(list => list.map(fromJson))).subscribe(next);
}
postNone(path: any[], data: any, next?: Next<void>): Subscription {
return this.http.post<void>(getApiUrl('http', path), data, {withCredentials: true}).subscribe(next);
}
postSingle<T>(path: any[], data: any, fromJson: FromJson<T>, next?: Next<T>): Subscription {
return this.http.post<any>(getApiUrl('http', path), data, {withCredentials: true}).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>(getApiUrl('http', path), data, {withCredentials: true}).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[]>(getApiUrl('http', path), data, {withCredentials: true}).pipe(map(list => list.map(fromJson))).subscribe(next);
}
}

View File

@ -0,0 +1,40 @@
import {Pipe, PipeTransform} from '@angular/core';
@Pipe({
name: 'relative',
standalone: true
})
export class RelativePipe implements PipeTransform {
transform(date: Date | undefined | null, now: Date, future: string = 'In', past: string = 'Seit', nowStr: string = 'Gerade eben'): string | null | undefined {
if (date === undefined || date === null) {
return date;
}
let prefix = past;
let totalMillis = now.getTime() - date.getTime();
if (totalMillis < 0) {
totalMillis = -totalMillis;
prefix = future;
}
const totalSeconds = Math.floor(totalMillis / 1000);
const totalMinutes = Math.floor(totalSeconds / 60);
const totalHours = Math.floor(totalMinutes / 60);
const days = Math.floor(totalHours / 24);
const seconds = totalSeconds % 60;
const minutes = totalMinutes % 60;
const hours = totalHours % 24;
if (totalSeconds < 1) {
return nowStr;
} else if (days > 0) {
return `${prefix} ${days} Tag${days === 1 ? '' : 'en'}`;
} else if (hours > 0) {
return `${prefix} ${hours} Stunde${hours === 1 ? '' : 'n'}`;
} else if (minutes > 0) {
return `${prefix} ${minutes} Minute${minutes === 1 ? '' : 'n'}`;
}
return `${prefix} ${seconds} Sekunde${seconds === 1 ? '' : 'n'}`
}
}

View File

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

View File

@ -0,0 +1,80 @@
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 || json === undefined) {
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 || json === undefined) {
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 || json === undefined) {
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 || json === undefined) {
return null;
}
return validateDate(json);
}
export function validateListIgnoreNullItems<T>(jsonList: any, fromJson: FromJson<T | null>): T[] {
return jsonList.map(fromJson).filter((item: T | null) => item !== null);
}
export function validateList<T>(jsonList: any, fromJson: FromJson<T>): T[] {
return jsonList.map(fromJson);
}
export function validateLocalDateOrNull(json: any): Date | null {
if (json === null || json === undefined) {
return null;
}
return validateLocalDate(json);
}
export function validateLocalDate(json: any): Date {
return new Date(validateString(json));
}
export function orNull<T, R>(item: T | null | undefined, map: (t: T) => R): R | null {
if (item === null || item === undefined) {
return null;
}
return map(item);
}

View File

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

View File

@ -0,0 +1,16 @@
<div class="flexBox">
<div class="flexBoxFixed menu">
<div class="item itemLeft" routerLink="DeviceList" routerLinkActive="active">Geräte</div>
<div class="item itemLeft" routerLink="ShutterList" routerLinkActive="active">Rollläden</div>
<div class="item itemRight" routerLink="GroupList" routerLinkActive="active">KNX</div>
</div>
<div class="flexBoxRest">
<router-outlet/>
</div>
</div>
<div id="notConnected" *ngIf="apiService.websocketError">
<div>
Nicht verbunden
</div>
</div>

View File

@ -0,0 +1,43 @@
@import "../config";
.menu {
border-bottom: @border solid black;
.item {
padding: @space;
}
.itemLeft {
float: left;
border-right: @border solid black;
}
.itemRight {
float: right;
border-left: @border solid black;
}
.active {
background-color: lightskyblue;
}
}
#notConnected {
position: fixed;
display: flex;
top: 0;
left: 0;
height: 100%;
width: 100%;
background-color: rgba(0, 0, 0, 0.8);
border: @space solid red;
color: red;
div {
text-align: center;
margin: auto;
font-size: 200%;
font-weight: bold;
}
}

View File

@ -0,0 +1,22 @@
import {Component} from '@angular/core';
import {RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router';
import {ApiService} from './api/common/api.service';
import {NgIf} from '@angular/common';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive, NgIf],
templateUrl: './app.component.html',
styleUrl: './app.component.less'
})
export class AppComponent {
title = 'angular';
constructor(
protected readonly apiService: ApiService,
) {
// -
}
}

View File

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

View File

@ -0,0 +1,11 @@
import {Routes} from '@angular/router';
import {KnxGroupListPageComponent} from './pages/knx-group-list-page/knx-group-list-page.component';
import {DeviceListPageComponent} from './pages/device-list-page/device-list-page.component';
import {ShutterListPageComponent} from './pages/shutter-list-page/shutter-list-page.component';
export const routes: Routes = [
{path: 'DeviceList', component: DeviceListPageComponent},
{path: 'ShutterList', component: ShutterListPageComponent},
{path: 'GroupList', component: KnxGroupListPageComponent},
{path: '**', redirectTo: 'GroupList'},
];

View File

@ -0,0 +1,8 @@
<div class="flexBox">
<div class="flexBoxFixed">
<input [(ngModel)]="filter.search" (ngModelChange)="fetchDelayed()" placeholder="Suchen...">
</div>
<div class="flexBoxRest">
<app-device-list [deviceList]="deviceList"></app-device-list>
</div>
</div>

View File

@ -0,0 +1,5 @@
@import "../../../config";
input {
border-bottom: @border solid lightgray;
}

View File

@ -0,0 +1,70 @@
import {Component, OnDestroy, OnInit} from '@angular/core';
import {DeviceListComponent} from '../../shared/device-list/device-list.component';
import {Device} from '../../api/Device/Device';
import {DeviceService} from '../../api/Device/device.service';
import {FormsModule} from '@angular/forms';
import {DeviceFilter} from '../../api/Device/DeviceFilter';
import {Subscription} from 'rxjs';
import {KnxGroupListComponent} from '../../shared/knx-group-list/knx-group-list.component';
import {ApiService} from '../../api/common/api.service';
@Component({
selector: 'app-device-list-page',
standalone: true,
imports: [
DeviceListComponent,
FormsModule,
KnxGroupListComponent
],
templateUrl: './device-list-page.component.html',
styleUrl: './device-list-page.component.less'
})
export class DeviceListPageComponent implements OnInit, OnDestroy {
private readonly subs: Subscription[] = [];
protected deviceList: Device[] = [];
protected filter: DeviceFilter = new DeviceFilter();
private fetchTimeout: any;
constructor(
protected readonly deviceService: DeviceService,
protected readonly apiService: ApiService,
) {
// -
}
ngOnInit(): void {
this.fetch();
this.subs.push(this.deviceService.subscribe(device => this.updateDevice(device)));
this.apiService.connected(() => this.fetch());
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
fetchDelayed() {
if (this.fetchTimeout) {
clearTimeout(this.fetchTimeout);
this.fetchTimeout = undefined;
}
this.fetchTimeout = setTimeout(() => this.fetch(), 300)
}
private fetch() {
this.deviceService.list(this.filter, list => this.deviceList = list)
}
private updateDevice(device: Device) {
const index = this.deviceList.findIndex(d => d.uuid === device.uuid);
if (index >= 0) {
this.deviceList.splice(index, 1, device);
} else {
this.fetch();
}
}
}

View File

@ -0,0 +1,8 @@
<div class="flexBox">
<div class="flexBoxFixed">
<input [(ngModel)]="filter.search" (ngModelChange)="fetchDelayed()" placeholder="Suchen...">
</div>
<div class="flexBoxRest">
<app-knx-group-list [groupList]="groupList"></app-knx-group-list>
</div>
</div>

View File

@ -0,0 +1,5 @@
@import "../../../config";
input {
border-bottom: @border solid lightgray;
}

View File

@ -0,0 +1,68 @@
import {Component, OnDestroy, OnInit} from '@angular/core';
import {KnxGroupListComponent} from '../../shared/knx-group-list/knx-group-list.component';
import {Group} from '../../api/Group/Group';
import {GroupService} from '../../api/Group/group.service';
import {FormsModule} from '@angular/forms';
import {GroupFilter} from '../../api/Group/GroupFilter';
import {Subscription} from 'rxjs';
import {ApiService} from '../../api/common/api.service';
@Component({
selector: 'app-knx-group-list-page',
standalone: true,
imports: [
KnxGroupListComponent,
FormsModule
],
templateUrl: './knx-group-list-page.component.html',
styleUrl: './knx-group-list-page.component.less'
})
export class KnxGroupListPageComponent implements OnInit, OnDestroy {
private readonly subs: Subscription[] = [];
protected groupList: Group[] = [];
protected filter: GroupFilter = new GroupFilter();
private fetchTimeout: any;
constructor(
protected readonly groupService: GroupService,
protected readonly apiService: ApiService,
) {
// -
}
ngOnInit(): void {
this.fetch();
this.subs.push(this.groupService.subscribe(group => this.updateGroup(group)));
this.apiService.connected(() => this.fetch());
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
fetchDelayed() {
if (this.fetchTimeout) {
clearTimeout(this.fetchTimeout);
this.fetchTimeout = undefined;
}
this.fetchTimeout = setTimeout(() => this.fetch(), 300)
}
private fetch() {
this.groupService.list(this.filter, list => this.groupList = list)
}
private updateGroup(group: Group) {
const index = this.groupList.findIndex(g => g.address === group.address);
if (index >= 0) {
this.groupList.splice(index, 1, group);
} else {
this.fetch();
}
}
}

View File

@ -0,0 +1,8 @@
<div class="flexBox">
<div class="flexBoxFixed">
<input [(ngModel)]="filter.search" (ngModelChange)="fetchDelayed()" placeholder="Suchen...">
</div>
<div class="flexBoxRest">
<app-shutter-list [shutterList]="shutterList"></app-shutter-list>
</div>
</div>

View File

@ -0,0 +1,5 @@
@import "../../../config";
input {
border-bottom: @border solid lightgray;
}

View File

@ -0,0 +1,70 @@
import {Component, OnDestroy, OnInit} from '@angular/core';
import {ShutterListComponent} from '../../shared/shutter-list/shutter-list.component';
import {Shutter} from '../../api/Shutter/Shutter';
import {ShutterService} from '../../api/Shutter/shutter.service';
import {FormsModule} from '@angular/forms';
import {ShutterFilter} from '../../api/Shutter/ShutterFilter';
import {Subscription} from 'rxjs';
import {KnxGroupListComponent} from '../../shared/knx-group-list/knx-group-list.component';
import {ApiService} from '../../api/common/api.service';
@Component({
selector: 'app-shutter-list-page',
standalone: true,
imports: [
ShutterListComponent,
FormsModule,
KnxGroupListComponent
],
templateUrl: './shutter-list-page.component.html',
styleUrl: './shutter-list-page.component.less'
})
export class ShutterListPageComponent implements OnInit, OnDestroy {
private readonly subs: Subscription[] = [];
protected shutterList: Shutter[] = [];
protected filter: ShutterFilter = new ShutterFilter();
private fetchTimeout: any;
constructor(
protected readonly shutterService: ShutterService,
protected readonly apiService: ApiService,
) {
// -
}
ngOnInit(): void {
this.fetch();
this.subs.push(this.shutterService.subscribe(shutter => this.updateShutter(shutter)));
this.apiService.connected(() => this.fetch());
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
fetchDelayed() {
if (this.fetchTimeout) {
clearTimeout(this.fetchTimeout);
this.fetchTimeout = undefined;
}
this.fetchTimeout = setTimeout(() => this.fetch(), 300)
}
private fetch() {
this.shutterService.list(this.filter, list => this.shutterList = list)
}
private updateShutter(shutter: Shutter) {
const index = this.shutterList.findIndex(d => d.uuid === shutter.uuid);
if (index >= 0) {
this.shutterList.splice(index, 1, shutter);
} else {
this.fetch();
}
}
}

View File

@ -0,0 +1,24 @@
<div class="deviceList tileContainer">
<div class="tile" *ngFor="let device of deviceList; trackBy: Device.trackBy">
<div class="device tileInner" [ngClass]="ngClass(device)">
<div class="name">
{{ device.name }}
</div>
<div class="actions">
<div class="switchOn" (click)="deviceService.setState(device, true)"></div>
<div class="switchOff" (click)="deviceService.setState(device, false)"></div>
</div>
<div class="timestamp details">
{{ device.stateProperty?.lastValueChange | relative:now }}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,43 @@
@import "../../../config";
.deviceList {
overflow-y: auto;
height: 100%;
.device {
.name {
float: left;
}
.timestamp {
clear: left;
float: left;
font-size: 80%;
}
.actions {
float: right;
div {
float: left;
margin-left: @space;
width: 4em;
aspect-ratio: 1;
}
.switchOn {
//noinspection CssUnknownTarget
background-image: url("/switchOn.svg");
}
.switchOff {
//noinspection CssUnknownTarget
background-image: url("/switchOff.svg");
}
}
}
}

View File

@ -0,0 +1,52 @@
import {Component, Input, OnDestroy, OnInit} from '@angular/core';
import {NgClass, NgForOf} from '@angular/common';
import {Device} from '../../api/Device/Device';
import {DeviceService} from '../../api/Device/device.service';
import {RelativePipe} from '../../api/common/relative.pipe';
import {Subscription, timer} from 'rxjs';
@Component({
selector: 'app-device-list',
standalone: true,
imports: [
NgForOf,
NgClass,
RelativePipe
],
templateUrl: './device-list.component.html',
styleUrl: './device-list.component.less'
})
export class DeviceListComponent implements OnInit, OnDestroy {
protected readonly Device = Device;
private readonly subs: Subscription[] = [];
protected now: Date = new Date();
@Input()
deviceList: Device[] = [];
constructor(
protected readonly deviceService: DeviceService,
) {
// -
}
ngOnInit(): void {
this.now = new Date();
this.subs.push(timer(1000, 1000).subscribe(() => this.now = new Date()));
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
ngClass(device: Device) {
return {
"stateOn": device.stateProperty?.state?.value === true,
"stateOff": device.stateProperty?.state?.value === false,
};
}
}

View File

@ -0,0 +1,35 @@
<div class="groupList tileContainer">
<div class="tile" *ngFor="let group of groupList; trackBy: Group.trackBy">
<div class="group tileInner" [ngClass]="ngClass(group)">
<div class="name">
{{ group.name }}
</div>
<div class="details">
<div class="stackLeft address">
{{ group.address }}
</div>
<div class="stackLeft dpt">
DPT {{ group.dpt }}
</div>
<div class="stackRight state">
{{ group.state?.string || '-' }}
</div>
<div class="stackRight timestamp">
{{ group.lastValueChange | relative:now }}:
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,15 @@
@import "../../../config";
.groupList {
overflow-y: auto;
height: 100%;
.group {
.name {
margin-bottom: @space;
}
}
}

View File

@ -0,0 +1,45 @@
import {Component, Input, OnDestroy, OnInit} from '@angular/core';
import {NgClass, NgForOf} from '@angular/common';
import {Group} from '../../api/Group/Group';
import {RelativePipe} from '../../api/common/relative.pipe';
import {Subscription, timer} from 'rxjs';
@Component({
selector: 'app-knx-group-list',
standalone: true,
imports: [
NgForOf,
NgClass,
RelativePipe
],
templateUrl: './knx-group-list.component.html',
styleUrl: './knx-group-list.component.less'
})
export class KnxGroupListComponent implements OnInit, OnDestroy {
protected readonly Group = Group;
private readonly subs: Subscription[] = [];
protected now: Date = new Date();
@Input()
groupList: Group[] = [];
ngClass(group: Group) {
return {
"stateOn": group.state?.value === true,
"stateOff": group.state?.value === false,
};
}
ngOnInit(): void {
this.now = new Date();
this.subs.push(timer(1000, 1000).subscribe(() => this.now = new Date()));
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
}

View File

@ -0,0 +1,5 @@
<div class="window" (click)="activate.emit(position)">
<div class="shutter" [style.height]="position + '%'">
<!-- -->
</div>
</div>

View File

@ -0,0 +1,14 @@
@import "../../../../config";
.window {
width: 100%;
height: 100%;
background-color: lightskyblue;
border: @border solid black;
.shutter {
background-color: saddlebrown;
border-bottom: @border solid black;
}
}

View File

@ -0,0 +1,18 @@
import {Component, EventEmitter, Input, Output} from '@angular/core';
@Component({
selector: 'app-shutter-icon',
standalone: true,
imports: [],
templateUrl: './shutter-icon.component.html',
styleUrl: './shutter-icon.component.less'
})
export class ShutterIconComponent {
@Input()
position?: number
@Output()
activate: EventEmitter<number> = new EventEmitter();
}

View File

@ -0,0 +1,41 @@
<div class="shutterList tileContainer">
<div class="tile" *ngFor="let shutter of shutterList; trackBy: Shutter.trackBy">
<div class="shutter tileInner">
<div class="name">
{{ shutter.name }}
</div>
<div class="icon">
<app-shutter-icon [position]="shutter.positionProperty?.state?.value"></app-shutter-icon>
</div>
<div class="timestamp details">
{{ shutter.positionProperty?.lastValueChange | relative:now }}
</div>
<div class="actions">
<div class="action">
<app-shutter-icon [position]="0" (activate)="shutterService.setPosition(shutter, $event)"></app-shutter-icon>
</div>
<div class="action">
<app-shutter-icon [position]="50" (activate)="shutterService.setPosition(shutter, $event)"></app-shutter-icon>
</div>
<div class="action">
<app-shutter-icon [position]="80" (activate)="shutterService.setPosition(shutter, $event)"></app-shutter-icon>
</div>
<div class="action">
<app-shutter-icon [position]="90" (activate)="shutterService.setPosition(shutter, $event)"></app-shutter-icon>
</div>
<div class="action">
<app-shutter-icon [position]="100" (activate)="shutterService.setPosition(shutter, $event)"></app-shutter-icon>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,40 @@
@import "../../../config";
.shutterList {
overflow-y: auto;
height: 100%;
.shutter {
.name {
float: left;
}
.icon {
clear: left;
float: left;
width: 4em;
aspect-ratio: 1;
}
.timestamp {
float: right;
font-size: 80%;
}
.actions {
clear: right;
float: right;
div {
float: left;
margin-left: @space;
width: 3em;
aspect-ratio: 1;
}
}
}
}

View File

@ -0,0 +1,47 @@
import {Component, Input, OnDestroy, OnInit} from '@angular/core';
import {NgClass, NgForOf} from '@angular/common';
import {Shutter} from '../../api/Shutter/Shutter';
import {ShutterService} from '../../api/Shutter/shutter.service';
import {RelativePipe} from '../../api/common/relative.pipe';
import {Subscription, timer} from 'rxjs';
import {ShutterIconComponent} from './shutter-icon/shutter-icon.component';
@Component({
selector: 'app-shutter-list',
standalone: true,
imports: [
NgForOf,
NgClass,
RelativePipe,
ShutterIconComponent
],
templateUrl: './shutter-list.component.html',
styleUrl: './shutter-list.component.less'
})
export class ShutterListComponent implements OnInit, OnDestroy {
protected readonly Shutter = Shutter;
private readonly subs: Subscription[] = [];
protected now: Date = new Date();
@Input()
shutterList: Shutter[] = [];
constructor(
protected readonly shutterService: ShutterService,
) {
// -
}
ngOnInit(): void {
this.now = new Date();
this.subs.push(timer(1000, 1000).subscribe(() => this.now = new Date()));
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
}

View File

@ -0,0 +1,110 @@
@space: 0.4em;
@border: 0.05em;
.flexBox {
display: flex;
flex-direction: column;
height: 100%;
.flexBoxFixed {
}
.flexBoxRest {
flex: 1;
}
}
.details {
font-size: 80%;
color: gray;
.stackLeft {
float: left;
margin-right: @space;
}
.stackRight {
float: right;
margin-left: @space;
}
}
.stateOn {
background-color: palegreen;
}
.stateOff {
background-color: indianred;
.details {
color: #575757;
}
}
.tileContainer {
padding: calc(@space / 2);
.tile {
float: left;
width: 100%;
padding: calc(@space / 2);
.tileInner {
padding: @space;
border: @border solid lightgray;
}
}
}
@media (min-width: 1000px) {
.tileContainer {
.tile {
width: 50%;
}
}
}
@media (min-width: 1300px) {
.tileContainer {
.tile {
width: 33.33%;
}
}
}
@media (min-width: 1700px) {
.tileContainer {
.tile {
width: 25%;
}
}
}
@media (min-width: 2100px) {
.tileContainer {
.tile {
width: 20%;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,33 @@
@import "./config";
html {
height: 100%;
}
body {
margin: 0;
font-family: sans-serif;
font-size: 4vmin;
overflow: hidden;
height: 100%;
}
div {
box-sizing: border-box;
overflow: hidden;
}
input {
all: unset;
width: calc(100% - 2 * 0.2em - @border);
padding-left: 0.2em;
padding-right: 0.2em;
}
@media (min-width: 1000px) {
body {
font-size: 16px;
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,16 @@
package de.ph87.home.common.crud;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.annotation.Nullable;
import lombok.Getter;
import lombok.ToString;
@Getter
@ToString
public abstract class AbstractSearchFilter implements ISearch {
@Nullable
@JsonProperty
private String search;
}

View File

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

View File

@ -0,0 +1,14 @@
package de.ph87.home.common.crud;
import lombok.NonNull;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class EntityNotFound extends RuntimeException {
public EntityNotFound(@NonNull final String key, @NonNull final Object value) {
super("Entity not found: %s=%s".formatted(key, value));
}
}

View File

@ -0,0 +1,34 @@
package de.ph87.home.common.crud;
import jakarta.annotation.Nullable;
import lombok.NonNull;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.stream.Stream;
public interface ISearch {
@Nullable
String getSearch();
default boolean search(@NonNull final String... fields) {
final String term = getSearch();
if (term == null) {
return true;
}
final List<String> haystack = Arrays.stream(fields).map(String::toString).map(String::toLowerCase).toList();
return splitWords(term).allMatch(word -> anyMatch(word, haystack));
}
@NonNull
default Stream<String> splitWords(@NonNull final String term) {
return Arrays.stream(term.toLowerCase(Locale.ROOT).replaceAll("^\\s|\\s$", "").split("\\s+"));
}
default boolean anyMatch(@NonNull final String needle, @NonNull final List<String> haystack) {
return haystack.stream().anyMatch(lowerCaseHayStack -> lowerCaseHayStack.contains(needle));
}
}

View File

@ -0,0 +1,20 @@
package de.ph87.home.common.json;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import java.io.IOException;
public class ClassSimpleNameSerializer extends JsonSerializer<Class<?>> {
@Override
public void serialize(final Class<?> aClass, final JsonGenerator jsonGenerator, final SerializerProvider serializerProvider) throws IOException {
if (aClass == null) {
jsonGenerator.writeNull();
} else {
jsonGenerator.writeString(aClass.getSimpleName());
}
}
}

View File

@ -1,4 +1,4 @@
package de.ph87.home.common; package de.ph87.home.common.json;
import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.DeserializationContext;
@ -9,7 +9,7 @@ import java.time.Instant;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.ZonedDateTime; import java.time.ZonedDateTime;
public class EpochSecondsToZonedDateTime extends JsonDeserializer<ZonedDateTime> { public class EpochSecondsToZonedDateTimeDeserializer extends JsonDeserializer<ZonedDateTime> {
@Override @Override
public ZonedDateTime deserialize(final JsonParser jsonParser, final DeserializationContext deserializationContext) throws IOException { public ZonedDateTime deserialize(final JsonParser jsonParser, final DeserializationContext deserializationContext) throws IOException {

View File

@ -1,4 +1,4 @@
package de.ph87.home.common; package de.ph87.home.common.json;
import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.DeserializationContext;
@ -7,7 +7,7 @@ import com.fasterxml.jackson.databind.JsonDeserializer;
import java.io.IOException; import java.io.IOException;
import java.time.Duration; import java.time.Duration;
public class SecondsToDuration extends JsonDeserializer<Duration> { public class SecondsToDurationDeserializer extends JsonDeserializer<Duration> {
@Override @Override
public Duration deserialize(final JsonParser jsonParser, final DeserializationContext deserializationContext) throws IOException { public Duration deserialize(final JsonParser jsonParser, final DeserializationContext deserializationContext) throws IOException {

View File

@ -3,6 +3,7 @@ package de.ph87.home.demo;
import de.ph87.home.device.DeviceService; import de.ph87.home.device.DeviceService;
import de.ph87.home.knx.property.KnxPropertyService; import de.ph87.home.knx.property.KnxPropertyService;
import de.ph87.home.knx.property.KnxPropertyType; import de.ph87.home.knx.property.KnxPropertyType;
import de.ph87.home.shutter.ShutterService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationStartedEvent; import org.springframework.boot.context.event.ApplicationStartedEvent;
@ -21,18 +22,42 @@ public class DemoService {
private final DeviceService deviceService; private final DeviceService deviceService;
private final ShutterService shutterService;
@EventListener(ApplicationStartedEvent.class) @EventListener(ApplicationStartedEvent.class)
public void startup() { public void startup() {
knxPropertyService.create("fernseher", KnxPropertyType.BOOLEAN, adr(20), adr(4)); knxPropertyService.create("eg_ambiente", KnxPropertyType.BOOLEAN, adr(849), adr(848));
knxPropertyService.create("verstaerker", KnxPropertyType.BOOLEAN, adr(825), adr(824));
knxPropertyService.create("receiver", KnxPropertyType.BOOLEAN, adr(2561), adr(2560));
deviceService.create("EG Ambiente", "eg_ambiente", "eg_ambiente"); deviceService.create("EG Ambiente", "eg_ambiente", "eg_ambiente");
knxPropertyService.create("fernseher", KnxPropertyType.BOOLEAN, adr(20), adr(4));
deviceService.create("Wohnzimmer Fernseher", "fernseher", "fernseher"); deviceService.create("Wohnzimmer Fernseher", "fernseher", "fernseher");
knxPropertyService.create("verstaerker", KnxPropertyType.BOOLEAN, adr(825), adr(824));
deviceService.create("Wohnzimmer Verstärker", "verstaerker", "verstaerker"); deviceService.create("Wohnzimmer Verstärker", "verstaerker", "verstaerker");
deviceService.create("Wohnzimmer Fensterdeko", "fensterdeko", "fensterdeko");
knxPropertyService.create("fensterdeko", KnxPropertyType.BOOLEAN, adr(1823), adr(1822));
deviceService.create("Wohnzimmer Fenster", "fensterdeko", "fensterdeko");
knxPropertyService.create("haengelampe", KnxPropertyType.BOOLEAN, adr(1794), adr(1799));
deviceService.create("Wohnzimmer Hängelampe", "haengelampe", "haengelampe"); deviceService.create("Wohnzimmer Hängelampe", "haengelampe", "haengelampe");
knxPropertyService.create("receiver", KnxPropertyType.BOOLEAN, adr(2561), adr(2560));
deviceService.create("Receiver", "receiver", "receiver"); deviceService.create("Receiver", "receiver", "receiver");
knxPropertyService.create("wohnzimmer_links", KnxPropertyType.DOUBLE, adr(1048), adr(1048));
shutterService.create("Wohnzimmer Links", "wohnzimmer_links", "wohnzimmer_links");
knxPropertyService.create("wohnzimmer_rechts", KnxPropertyType.DOUBLE, adr(1811), adr(1811));
shutterService.create("Wohnzimmer Rechts", "wohnzimmer_rechts", "wohnzimmer_rechts");
knxPropertyService.create("kueche_seite", KnxPropertyType.DOUBLE, adr(2316), adr(2316));
shutterService.create("Küche Seite", "kueche_seite", "kueche_seite");
knxPropertyService.create("kueche_theke", KnxPropertyType.DOUBLE, adr(2320), adr(2320));
shutterService.create("Küche Theke", "kueche_theke", "kueche_theke");
knxPropertyService.create("kueche_tuer", KnxPropertyType.DOUBLE, adr(2324), adr(2324));
shutterService.create("Küche Tür", "kueche_tuer", "kueche_tuer");
} }
private static GroupAddress adr(final int rawGroupAddress) { private static GroupAddress adr(final int rawGroupAddress) {

View File

@ -28,12 +28,12 @@ public class Device {
@Setter @Setter
@NonNull @NonNull
@Column(nullable = false) @Column(nullable = false)
private String stateProperty; private String statePropertyId;
public Device(@NonNull final String name, @NonNull final String slug, @NonNull final String stateProperty) { public Device(@NonNull final String name, @NonNull final String slug, @NonNull final String statePropertyId) {
this.name = name; this.name = name;
this.slug = slug; this.slug = slug;
this.stateProperty = stateProperty; this.statePropertyId = statePropertyId;
} }
} }

View File

@ -9,6 +9,7 @@ import lombok.NonNull;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import tuwien.auto.calimero.KNXFormatException;
import java.util.List; import java.util.List;
@ -20,6 +21,14 @@ public class DeviceController {
private final DeviceService deviceService; private final DeviceService deviceService;
@NonNull
@GetMapping("getByUuid/{id}")
@ExceptionHandler(KNXFormatException.class)
private DeviceDto getByUuid(@PathVariable final String id, @NonNull final HttpServletRequest request) {
log.debug("getByUuid: path={}", request.getServletPath());
return deviceService.getByUuidDto(id);
}
@NonNull @NonNull
@RequestMapping(value = "list", method = {RequestMethod.GET, RequestMethod.POST}) @RequestMapping(value = "list", method = {RequestMethod.GET, RequestMethod.POST})
private List<DeviceDto> list(@RequestBody(required = false) @Nullable final DeviceFilter filter, @NonNull final HttpServletRequest request) throws PropertyTypeMismatch { private List<DeviceDto> list(@RequestBody(required = false) @Nullable final DeviceFilter filter, @NonNull final HttpServletRequest request) throws PropertyTypeMismatch {
@ -29,20 +38,20 @@ public class DeviceController {
@NonNull @NonNull
@GetMapping("get/{uuidOrSlug}") @GetMapping("get/{uuidOrSlug}")
private DeviceDto get(@PathVariable @NonNull final String uuidOrSlug, @NonNull final HttpServletRequest request) throws DeviceNotFound { private DeviceDto get(@PathVariable @NonNull final String uuidOrSlug, @NonNull final HttpServletRequest request) {
log.debug("get: path={}", request.getServletPath()); log.debug("get: path={}", request.getServletPath());
return deviceService.toDto(uuidOrSlug); return deviceService.getByUuidOrSlugDto(uuidOrSlug);
} }
@Nullable @Nullable
@GetMapping("getState/{uuidOrSlug}") @GetMapping("getState/{uuidOrSlug}")
private Boolean getState(@PathVariable @NonNull final String uuidOrSlug, @NonNull final HttpServletRequest request) throws DeviceNotFound, PropertyTypeMismatch { private Boolean getState(@PathVariable @NonNull final String uuidOrSlug, @NonNull final HttpServletRequest request) throws PropertyTypeMismatch {
log.debug("getState: path={}", request.getServletPath()); log.debug("getState: path={}", request.getServletPath());
return deviceService.toDto(uuidOrSlug).getStateValue(); return deviceService.getByUuidOrSlugDto(uuidOrSlug).getStateValue();
} }
@GetMapping("setState/{uuidOrSlug}/{state}") @GetMapping("setState/{uuidOrSlug}/{state}")
private void setState(@PathVariable @NonNull final String uuidOrSlug, @PathVariable final boolean state, @NonNull final HttpServletRequest request) throws PropertyNotFound, DeviceNotFound, PropertyNotWritable, PropertyTypeMismatch { private void setState(@PathVariable @NonNull final String uuidOrSlug, @PathVariable final boolean state, @NonNull final HttpServletRequest request) throws PropertyNotFound, PropertyNotWritable, PropertyTypeMismatch {
log.debug("setState: path={}", request.getServletPath()); log.debug("setState: path={}", request.getServletPath());
deviceService.setState(uuidOrSlug, state); deviceService.setState(uuidOrSlug, state);
} }

View File

@ -2,14 +2,20 @@ package de.ph87.home.device;
import de.ph87.home.property.PropertyDto; import de.ph87.home.property.PropertyDto;
import de.ph87.home.property.PropertyTypeMismatch; import de.ph87.home.property.PropertyTypeMismatch;
import de.ph87.home.web.IWebSocketMessage;
import jakarta.annotation.Nullable; import jakarta.annotation.Nullable;
import lombok.Getter; import lombok.Getter;
import lombok.NonNull; import lombok.NonNull;
import lombok.ToString; import lombok.ToString;
import java.util.List;
@Getter @Getter
@ToString @ToString
public class DeviceDto { public class DeviceDto implements IWebSocketMessage {
@ToString.Exclude
private final List<Object> websocketTopic = List.of("Device");
@NonNull @NonNull
private final String uuid; private final String uuid;
@ -21,18 +27,18 @@ public class DeviceDto {
private final String slug; private final String slug;
@NonNull @NonNull
private final String stateConfig; private final String statePropertyId;
@Nullable @Nullable
@ToString.Exclude @ToString.Exclude
private final PropertyDto<Boolean> state; private final PropertyDto<Boolean> stateProperty;
public DeviceDto(@NonNull final Device device, @Nullable final PropertyDto<Boolean> state) { public DeviceDto(@NonNull final Device device, @Nullable final PropertyDto<Boolean> stateProperty) {
this.uuid = device.getUuid(); this.uuid = device.getUuid();
this.name = device.getName(); this.name = device.getName();
this.slug = device.getSlug(); this.slug = device.getSlug();
this.stateConfig = device.getStateProperty(); this.statePropertyId = device.getStatePropertyId();
this.state = state; this.stateProperty = stateProperty;
} }
@Nullable @Nullable
@ -47,10 +53,10 @@ public class DeviceDto {
@Nullable @Nullable
public Boolean getStateValue() throws PropertyTypeMismatch { public Boolean getStateValue() throws PropertyTypeMismatch {
if (state == null) { if (stateProperty == null) {
return null; return null;
} }
return state.getStateValueAs(Boolean.class); return stateProperty.getStateValueAs(Boolean.class);
} }
} }

View File

@ -1,6 +1,7 @@
package de.ph87.home.device; package de.ph87.home.device;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import de.ph87.home.common.crud.AbstractSearchFilter;
import de.ph87.home.property.PropertyTypeMismatch; import de.ph87.home.property.PropertyTypeMismatch;
import jakarta.annotation.Nullable; import jakarta.annotation.Nullable;
import lombok.Getter; import lombok.Getter;
@ -9,7 +10,7 @@ import lombok.ToString;
@Getter @Getter
@ToString @ToString
public class DeviceFilter { public class DeviceFilter extends AbstractSearchFilter {
@Nullable @Nullable
@JsonProperty @JsonProperty
@ -23,19 +24,18 @@ public class DeviceFilter {
@JsonProperty @JsonProperty
private Boolean stateFalse; private Boolean stateFalse;
@SuppressWarnings("RedundantIfStatement")
public boolean filter(@NonNull final DeviceDto dto) throws PropertyTypeMismatch { public boolean filter(@NonNull final DeviceDto dto) throws PropertyTypeMismatch {
if (stateNull != null && stateNull != (dto.getState() == null)) { if (stateNull != null && stateNull != (dto.getStateProperty() == null)) {
return false; return false;
} }
final Boolean value = dto.getStateValue(); final Boolean value = dto.getStateValue();
if (stateTrue != null && (dto.getState() == null || stateTrue != value)) { if (stateTrue != null && (value == null || stateTrue != value)) {
return false; return false;
} }
if (stateFalse != null && (dto.getState() == null || stateFalse == value)) { if (stateFalse != null && (value == null || stateFalse == value)) {
return false; return false;
} }
return true; return search(dto.getName());
} }
} }

View File

@ -1,14 +0,0 @@
package de.ph87.home.device;
import lombok.NonNull;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class DeviceNotFound extends Exception {
public DeviceNotFound(@NonNull final String key, final @NonNull String value) {
super("Device not found: %s=%s".formatted(key, value));
}
}

View File

@ -10,6 +10,6 @@ public interface DeviceRepository extends ListCrudRepository<Device, String> {
Optional<Device> findByUuidOrSlug(@NonNull String uuid, @NonNull String slug); Optional<Device> findByUuidOrSlug(@NonNull String uuid, @NonNull String slug);
List<Device> findAllByStateProperty(@NonNull String propertyId); List<Device> findAllByStatePropertyId(@NonNull String propertyId);
} }

View File

@ -1,5 +1,7 @@
package de.ph87.home.device; package de.ph87.home.device;
import de.ph87.home.common.crud.CrudAction;
import de.ph87.home.common.crud.EntityNotFound;
import de.ph87.home.property.*; import de.ph87.home.property.*;
import jakarta.annotation.Nullable; import jakarta.annotation.Nullable;
import lombok.NonNull; import lombok.NonNull;
@ -27,29 +29,34 @@ public class DeviceService {
@NonNull @NonNull
public DeviceDto create(@NonNull final String name, @NonNull final String slug, @NonNull final String stateProperty) { public DeviceDto create(@NonNull final String name, @NonNull final String slug, @NonNull final String stateProperty) {
return toDto(deviceRepository.save(new Device(name, slug, stateProperty))); return publish(deviceRepository.save(new Device(name, slug, stateProperty)), CrudAction.UPDATED);
} }
public void setState(@NonNull final String uuidOrSlug, final boolean state) throws DeviceNotFound, PropertyNotFound, PropertyNotWritable, PropertyTypeMismatch { public void setState(@NonNull final String uuidOrSlug, final boolean state) throws PropertyNotFound, PropertyNotWritable, PropertyTypeMismatch {
log.debug("setState: uuidOrSlug={}, state={}", uuidOrSlug, state); log.debug("setState: uuidOrSlug={}, state={}", uuidOrSlug, state);
final Device device = getByUuidOrSlug(uuidOrSlug); final Device device = getByUuidOrSlug(uuidOrSlug);
propertyService.write(device.getStateProperty(), state, Boolean.class); propertyService.write(device.getStatePropertyId(), state, Boolean.class);
} }
@NonNull @NonNull
public DeviceDto toDto(final @NonNull String uuidOrSlug) throws DeviceNotFound { public DeviceDto getByUuidOrSlugDto(final @NonNull String uuidOrSlug) {
return toDto(getByUuidOrSlug(uuidOrSlug)); return toDto(getByUuidOrSlug(uuidOrSlug));
} }
@NonNull
private Device getByUuidOrSlug(@NonNull final String uuidOrSlug) {
return deviceRepository.findByUuidOrSlug(uuidOrSlug, uuidOrSlug).orElseThrow(() -> new EntityNotFound("uuidOrSlug", uuidOrSlug));
}
@NonNull @NonNull
public DeviceDto toDto(@NonNull final Device device) { public DeviceDto toDto(@NonNull final Device device) {
final PropertyDto<Boolean> state = propertyService.dtoByIdAndTypeOrNull(device.getStateProperty(), Boolean.class); final PropertyDto<Boolean> state = propertyService.dtoByIdAndTypeOrNull(device.getStatePropertyId(), Boolean.class);
return new DeviceDto(device, state); return new DeviceDto(device, state);
} }
@NonNull @NonNull
private Device getByUuidOrSlug(@NonNull final String uuidOrSlug) throws DeviceNotFound { private Device getByUuid(@NonNull final String uuid) {
return deviceRepository.findByUuidOrSlug(uuidOrSlug, uuidOrSlug).orElseThrow(() -> new DeviceNotFound("uuidOrSlug", uuidOrSlug)); return deviceRepository.findById(uuid).orElseThrow(() -> new EntityNotFound("uuid", uuid));
} }
@NonNull @NonNull
@ -69,13 +76,20 @@ public class DeviceService {
@EventListener(PropertyDto.class) @EventListener(PropertyDto.class)
public void onPropertyChange(@NonNull final PropertyDto<?> dto) { public void onPropertyChange(@NonNull final PropertyDto<?> dto) {
deviceRepository.findAllByStateProperty(dto.getId()).forEach(this::publish); deviceRepository.findAllByStatePropertyId(dto.getId()).forEach(device -> publish(device, CrudAction.CREATED));
} }
private void publish(@NonNull final Device device) { @NonNull
final DeviceDto deviceDto = toDto(device); private DeviceDto publish(@NonNull final Device device, @NonNull final CrudAction action) {
log.info("Device updated: {}", deviceDto); final DeviceDto dto = toDto(device);
applicationEventPublisher.publishEvent(deviceDto); log.info("Device {}: {}", action, dto);
applicationEventPublisher.publishEvent(dto);
return dto;
}
@NonNull
public DeviceDto getByUuidDto(@NonNull final String uuid) {
return toDto(getByUuid(uuid));
} }
} }

View File

@ -1,10 +1,14 @@
package de.ph87.home.knx; package de.ph87.home.knx.group;
import de.ph87.home.knx.group.dpt.DPT;
import de.ph87.home.property.State;
import jakarta.annotation.Nullable;
import lombok.Getter; import lombok.Getter;
import lombok.NonNull; import lombok.NonNull;
import lombok.ToString; import lombok.ToString;
import tuwien.auto.calimero.GroupAddress; import tuwien.auto.calimero.GroupAddress;
import java.time.ZonedDateTime;
import java.util.Objects; import java.util.Objects;
@Getter @Getter
@ -31,6 +35,12 @@ public class Group {
@ToString.Exclude @ToString.Exclude
private long puid; private long puid;
@Nullable
private State<?> state;
@Nullable
private ZonedDateTime lastValueChange = null;
public Group(@NonNull final String id, @NonNull final GroupAddress address, @NonNull final String name, @NonNull final String description, @NonNull final DPT dpt, final long puid) { public Group(@NonNull final String id, @NonNull final GroupAddress address, @NonNull final String name, @NonNull final String description, @NonNull final DPT dpt, final long puid) {
this.id = id; this.id = id;
this.address = address; this.address = address;
@ -40,6 +50,13 @@ public class Group {
this.puid = puid; this.puid = puid;
} }
public void setState(@NonNull final State<?> newState) {
if (newState.valueChanged(this.state)) {
lastValueChange = ZonedDateTime.now();
}
this.state = newState;
}
@Override @Override
public boolean equals(final Object o) { public boolean equals(final Object o) {
if (this == o) { if (this == o) {

View File

@ -1,4 +1,4 @@
package de.ph87.home.knx; package de.ph87.home.knx.group;
import io.micrometer.common.lang.Nullable; import io.micrometer.common.lang.Nullable;
import jakarta.persistence.AttributeConverter; import jakarta.persistence.AttributeConverter;

View File

@ -0,0 +1,37 @@
package de.ph87.home.knx.group;
import jakarta.annotation.Nullable;
import jakarta.servlet.http.HttpServletRequest;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import tuwien.auto.calimero.GroupAddress;
import tuwien.auto.calimero.KNXFormatException;
import java.util.List;
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("Knx/Group")
public class GroupController {
private final GroupService groupService;
@NonNull
@GetMapping("getByAddress/{id}")
@ExceptionHandler(KNXFormatException.class)
private GroupDto getByAddress(@PathVariable final String id, @NonNull final HttpServletRequest request) throws KNXFormatException {
log.debug("getByAddress: path={}", request.getServletPath());
return groupService.getByAddressDto(GroupAddress.from(id));
}
@NonNull
@RequestMapping(value = "list", method = {RequestMethod.GET, RequestMethod.POST})
private List<GroupDto> list(@RequestBody(required = false) @Nullable final GroupFilter filter, @NonNull final HttpServletRequest request) {
log.debug("list: path={} filter={}", request.getServletPath(), filter);
return groupService.list(filter);
}
}

View File

@ -0,0 +1,57 @@
package de.ph87.home.knx.group;
import de.ph87.home.property.State;
import de.ph87.home.web.IWebSocketMessage;
import jakarta.annotation.Nullable;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import java.time.ZonedDateTime;
import java.util.List;
@Getter
@ToString
public class GroupDto implements IWebSocketMessage {
@ToString.Exclude
private final List<Object> websocketTopic = List.of("Knx", "Group");
@NonNull
@ToString.Exclude
private final String id;
@NonNull
private final String address;
@NonNull
private final String name;
@NonNull
@ToString.Exclude
private final String description;
@NonNull
private final String dpt;
@ToString.Exclude
private final long puid;
@Nullable
private final State<?> state;
@Nullable
private final ZonedDateTime lastValueChange;
public GroupDto(@NonNull final Group group) {
this.id = group.getId();
this.address = group.getAddress().toString();
this.name = group.getName();
this.description = group.getDescription();
this.dpt = group.getDpt().toString();
this.puid = group.getPuid();
this.state = group.getState();
this.lastValueChange = group.getLastValueChange();
}
}

View File

@ -0,0 +1,16 @@
package de.ph87.home.knx.group;
import de.ph87.home.common.crud.AbstractSearchFilter;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
@Getter
@ToString
public class GroupFilter extends AbstractSearchFilter {
public boolean filter(@NonNull final Group group) {
return search(group.getName(), group.getAddress().toString());
}
}

View File

@ -1,4 +1,4 @@
package de.ph87.home.knx; package de.ph87.home.knx.group;
import lombok.Getter; import lombok.Getter;
import lombok.ToString; import lombok.ToString;

View File

@ -1,5 +1,8 @@
package de.ph87.home.knx; package de.ph87.home.knx.group;
import de.ph87.home.knx.group.dpt.DPT;
import de.ph87.home.knx.group.dpt.DPTException;
import jakarta.annotation.Nullable;
import lombok.NonNull; import lombok.NonNull;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -23,7 +26,7 @@ import java.util.Optional;
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class GroupService { public class GroupRepository {
private final List<Group> groupList = new ArrayList<>(); private final List<Group> groupList = new ArrayList<>();
@ -77,4 +80,14 @@ public class GroupService {
} }
} }
@NonNull
public List<Group> findAll(@Nullable final GroupFilter filter) {
synchronized (groupList) {
if (filter == null) {
return new ArrayList<>(groupList);
}
return groupList.stream().filter(filter::filter).toList();
}
}
} }

View File

@ -0,0 +1,54 @@
package de.ph87.home.knx.group;
import de.ph87.home.common.crud.EntityNotFound;
import jakarta.annotation.Nullable;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import tuwien.auto.calimero.GroupAddress;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class GroupService {
private final GroupRepository groupRepository;
private final ApplicationEventPublisher applicationEventPublisher;
@NonNull
public GroupDto toDto(@NonNull final Group group) {
return new GroupDto(group);
}
@NonNull
public List<GroupDto> list() {
return list(null);
}
@NonNull
public List<GroupDto> list(@Nullable final GroupFilter filter) {
return groupRepository.findAll(filter).stream().map(this::toDto).toList();
}
public void publish(@NonNull final Group group) {
final GroupDto groupDto = toDto(group);
log.info("Group updated: {}", groupDto);
applicationEventPublisher.publishEvent(groupDto);
}
@NonNull
public GroupDto getByAddressDto(@NonNull final GroupAddress address) {
return toDto(getByAddress(address));
}
@NonNull
private Group getByAddress(@NonNull final GroupAddress address) {
return groupRepository.findByAddress(address).orElseThrow(() -> new EntityNotFound("address", address));
}
}

View File

@ -1,4 +1,4 @@
package de.ph87.home.knx; package de.ph87.home.knx.group.dpt;
import lombok.Getter; import lombok.Getter;
import lombok.NonNull; import lombok.NonNull;

View File

@ -1,4 +1,4 @@
package de.ph87.home.knx; package de.ph87.home.knx.group.dpt;
import lombok.NonNull; import lombok.NonNull;

View File

@ -1,6 +1,6 @@
package de.ph87.home.knx.link; package de.ph87.home.knx.link;
import de.ph87.home.knx.Group; import de.ph87.home.knx.group.Group;
import de.ph87.home.knx.KnxConfig; import de.ph87.home.knx.KnxConfig;
import de.ph87.home.knx.link.request.Request; import de.ph87.home.knx.link.request.Request;
import de.ph87.home.knx.link.request.RequestRead; import de.ph87.home.knx.link.request.RequestRead;

View File

@ -1,6 +1,6 @@
package de.ph87.home.knx.link.request; package de.ph87.home.knx.link.request;
import de.ph87.home.knx.Group; import de.ph87.home.knx.group.Group;
import lombok.Getter; import lombok.Getter;
import lombok.NonNull; import lombok.NonNull;

View File

@ -1,6 +1,6 @@
package de.ph87.home.knx.link.request; package de.ph87.home.knx.link.request;
import de.ph87.home.knx.Group; import de.ph87.home.knx.group.Group;
import lombok.NonNull; import lombok.NonNull;
public class RequestRead extends Request { public class RequestRead extends Request {

View File

@ -1,6 +1,6 @@
package de.ph87.home.knx.link.request; package de.ph87.home.knx.link.request;
import de.ph87.home.knx.Group; import de.ph87.home.knx.group.Group;
import lombok.Getter; import lombok.Getter;
import lombok.NonNull; import lombok.NonNull;
import tuwien.auto.calimero.dptxlator.DPTXlator; import tuwien.auto.calimero.dptxlator.DPTXlator;

View File

@ -1,6 +1,6 @@
package de.ph87.home.knx.property; package de.ph87.home.knx.property;
import de.ph87.home.knx.GroupAddressJpaConverter; import de.ph87.home.knx.group.GroupAddressJpaConverter;
import jakarta.annotation.Nullable; import jakarta.annotation.Nullable;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Convert; import jakarta.persistence.Convert;

View File

@ -8,6 +8,6 @@ import java.util.List;
public interface KnxPropertyRepository extends ListCrudRepository<KnxProperty, String> { public interface KnxPropertyRepository extends ListCrudRepository<KnxProperty, String> {
List<KnxProperty> findDistinctByReadOrWrite(@NonNull GroupAddress read, @NonNull GroupAddress write); List<KnxProperty> findAllByRead(@NonNull GroupAddress read);
} }

View File

@ -1,13 +1,11 @@
package de.ph87.home.knx.property; package de.ph87.home.knx.property;
import de.ph87.home.knx.Group; import de.ph87.home.knx.group.Group;
import de.ph87.home.knx.GroupLoaded; import de.ph87.home.knx.group.GroupLoaded;
import de.ph87.home.knx.GroupService; import de.ph87.home.knx.group.GroupRepository;
import de.ph87.home.knx.group.GroupService;
import de.ph87.home.knx.link.KnxLinkService; import de.ph87.home.knx.link.KnxLinkService;
import de.ph87.home.property.PropertyNotFound; import de.ph87.home.property.*;
import de.ph87.home.property.PropertyNotOwned;
import de.ph87.home.property.PropertyService;
import de.ph87.home.property.PropertyTypeMismatch;
import jakarta.annotation.Nullable; import jakarta.annotation.Nullable;
import lombok.NonNull; import lombok.NonNull;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -38,10 +36,12 @@ public class KnxPropertyService {
private final PropertyService propertyService; private final PropertyService propertyService;
private final GroupService groupService; private final GroupRepository groupRepository;
private final KnxLinkService knxLinkService; private final KnxLinkService knxLinkService;
private final GroupService groupService;
@Scheduled(initialDelay = 1, fixedDelay = 1, timeUnit = TimeUnit.HOURS) @Scheduled(initialDelay = 1, fixedDelay = 1, timeUnit = TimeUnit.HOURS)
public void readAll() { public void readAll() {
knxPropertyRepository.findAll().forEach(this::read); knxPropertyRepository.findAll().forEach(this::read);
@ -49,7 +49,7 @@ public class KnxPropertyService {
@EventListener(GroupLoaded.class) @EventListener(GroupLoaded.class)
public void onGroupLoad(@NonNull final GroupLoaded groupLoaded) { public void onGroupLoad(@NonNull final GroupLoaded groupLoaded) {
findAllByAddress(groupLoaded.getGroup().getAddress()).forEach(this::read); findAllByReadAddress(groupLoaded.getGroup().getAddress()).forEach(this::read);
} }
public void create(@NonNull final String id, @NonNull final KnxPropertyType type, @Nullable final GroupAddress read, @Nullable final GroupAddress write) { public void create(@NonNull final String id, @NonNull final KnxPropertyType type, @Nullable final GroupAddress read, @Nullable final GroupAddress write) {
@ -68,17 +68,12 @@ public class KnxPropertyService {
@EventListener(ProcessEvent.class) @EventListener(ProcessEvent.class)
public void onProcessEvent(@NonNull final ProcessEvent event) { public void onProcessEvent(@NonNull final ProcessEvent event) {
findAllByAddress(event.getDestination()).forEach(knxProperty -> onProcessEvent(knxProperty, event)); findAllByReadAddress(event.getDestination()).forEach(knxProperty -> onProcessEvent(knxProperty, event));
}
@NonNull
private List<KnxProperty> findAllByAddress(@NonNull final GroupAddress address) {
return knxPropertyRepository.findDistinctByReadOrWrite(address, address);
} }
private void onProcessEvent(@NonNull final KnxProperty knxProperty, @NonNull final ProcessEvent event) { private void onProcessEvent(@NonNull final KnxProperty knxProperty, @NonNull final ProcessEvent event) {
log.debug("onProcessEvent: knxProperty={}, event={}", knxProperty, event); log.debug("onProcessEvent: knxProperty={}, event={}", knxProperty, event);
groupService.findByAddress(event.getDestination()).ifPresent(group -> onProcessEvent(knxProperty, event, group)); groupRepository.findByAddress(event.getDestination()).ifPresent(group -> onProcessEvent(knxProperty, event, group));
} }
private void onProcessEvent(@NonNull final KnxProperty knxProperty, @NonNull final ProcessEvent event, @NonNull final Group group) { private void onProcessEvent(@NonNull final KnxProperty knxProperty, @NonNull final ProcessEvent event, @NonNull final Group group) {
@ -87,15 +82,20 @@ public class KnxPropertyService {
final DPTXlator translator = group.getDpt().createTranslator(); final DPTXlator translator = group.getDpt().createTranslator();
translator.setData(event.getASDU()); translator.setData(event.getASDU());
log.debug("translator: {}", translator); log.debug("translator: {}", translator);
switch (knxProperty.getType()) { final Property<?> property = switch (knxProperty.getType()) {
case BOOLEAN -> { case BOOLEAN -> {
if (!(translator instanceof final DPTXlatorBoolean booleanTranslator)) { if (!(translator instanceof final DPTXlatorBoolean booleanTranslator)) {
throw new RuntimeException("DPTXlator type should be DPTXlatorBoolean for property.type = BOOLEAN but is: " + translator.getClass().getSimpleName()); throw new RuntimeException("DPTXlator type should be DPTXlatorBoolean for property.type = BOOLEAN but is: " + translator.getClass().getSimpleName());
} }
propertyService.update(this, knxProperty.getId(), Boolean.class, booleanTranslator.getValueBoolean(), booleanTranslator.getValue()); yield propertyService.update(this, knxProperty.getId(), Boolean.class, booleanTranslator.getValueBoolean(), booleanTranslator.getValue());
} }
case DOUBLE -> propertyService.update(this, knxProperty.getId(), Double.class, translator.getNumericValue(), translator.getValue()); case DOUBLE -> propertyService.update(this, knxProperty.getId(), Double.class, translator.getNumericValue(), translator.getValue());
};
if (property.getState() == null) {
throw new RuntimeException();
} }
group.setState(property.getState());
groupService.publish(group);
} catch (KNXException | PropertyNotFound | PropertyTypeMismatch | PropertyNotOwned e) { } catch (KNXException | PropertyNotFound | PropertyTypeMismatch | PropertyNotOwned e) {
log.error("Failed to handle ProcessEvent: knxProperty={}, error={}", knxProperty, e.toString()); log.error("Failed to handle ProcessEvent: knxProperty={}, error={}", knxProperty, e.toString());
} }
@ -104,12 +104,12 @@ public class KnxPropertyService {
private void read(@NonNull final KnxProperty knxProperty) { private void read(@NonNull final KnxProperty knxProperty) {
final Optional<KnxProperty> property = knxPropertyRepository.findById(knxProperty.getId()); final Optional<KnxProperty> property = knxPropertyRepository.findById(knxProperty.getId());
final Optional<GroupAddress> address = property.map(KnxProperty::getRead); final Optional<GroupAddress> address = property.map(KnxProperty::getRead);
final Optional<Group> group = address.map(groupService::findByAddress).filter(Optional::isPresent).map(Optional::get); final Optional<Group> group = address.map(groupRepository::findByAddress).filter(Optional::isPresent).map(Optional::get);
group.ifPresent(knxLinkService::queueRead); group.ifPresent(knxLinkService::queueRead);
} }
private void write(@NonNull final KnxProperty knxProperty, @NonNull final Object value) { private void write(@NonNull final KnxProperty knxProperty, @NonNull final Object value) {
knxPropertyRepository.findById(knxProperty.getId()).map(KnxProperty::getWrite).map(groupService::findByAddress).filter(Optional::isPresent).map(Optional::get).ifPresent(group -> write(knxProperty, group, value)); knxPropertyRepository.findById(knxProperty.getId()).map(KnxProperty::getWrite).map(groupRepository::findByAddress).filter(Optional::isPresent).map(Optional::get).ifPresent(group -> write(knxProperty, group, value));
} }
private void write(@NonNull final KnxProperty knxProperty, @NonNull final Group group, @NonNull final Object value) { private void write(@NonNull final KnxProperty knxProperty, @NonNull final Group group, @NonNull final Object value) {
@ -133,4 +133,9 @@ public class KnxPropertyService {
} }
} }
@NonNull
private List<KnxProperty> findAllByReadAddress(@NonNull final GroupAddress address) {
return knxPropertyRepository.findAllByRead(address);
}
} }

View File

@ -6,6 +6,7 @@ import lombok.NonNull;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.ToString; import lombok.ToString;
import java.time.ZonedDateTime;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
import java.util.function.Consumer; import java.util.function.Consumer;
@ -43,16 +44,17 @@ public class Property<T> implements IProperty<T> {
@ToString.Exclude @ToString.Exclude
private final Consumer<Property<T>> onStateSet; private final Consumer<Property<T>> onStateSet;
@Nullable
@ToString.Exclude
private State<T> lastState = null;
@Nullable @Nullable
private State<T> state = null; private State<T> state = null;
public void update(@Nullable final State<T> state) { @Nullable
this.lastState = this.state; private ZonedDateTime lastValueChange = null;
this.state = state;
public void update(@NonNull final State<T> newState) {
if (newState.valueChanged(this.state)) {
lastValueChange = ZonedDateTime.now();
}
this.state = newState;
this.onStateSet.accept(this); this.onStateSet.accept(this);
} }

View File

@ -5,6 +5,8 @@ import lombok.Getter;
import lombok.NonNull; import lombok.NonNull;
import lombok.ToString; import lombok.ToString;
import java.time.ZonedDateTime;
@Getter @Getter
@ToString @ToString
public class PropertyDto<T> implements IProperty<T> { public class PropertyDto<T> implements IProperty<T> {
@ -22,17 +24,16 @@ public class PropertyDto<T> implements IProperty<T> {
} }
@Nullable @Nullable
@ToString.Exclude private final State<T> state;
private final State<T> lastState;
@Nullable @Nullable
private final State<T> state; private final ZonedDateTime lastValueChange;
public PropertyDto(@NonNull final Property<T> property) { public PropertyDto(@NonNull final Property<T> property) {
this.id = property.getId(); this.id = property.getId();
this.type = property.getType(); this.type = property.getType();
this.state = property.getState(); this.state = property.getState();
this.lastState = property.getLastState(); this.lastValueChange = property.getLastValueChange();
} }
} }

View File

@ -104,9 +104,10 @@ public class PropertyService {
return new PropertyDto<>(property); return new PropertyDto<>(property);
} }
public <T> void update(@NonNull final Object owner, @NonNull final String id, @NonNull final Class<T> type, @Nullable final T value, @NonNull final String string) throws PropertyNotFound, PropertyNotOwned, PropertyTypeMismatch { public <T> Property<T> update(@NonNull final Object owner, @NonNull final String id, @NonNull final Class<T> type, @Nullable final T value, @NonNull final String string) throws PropertyNotFound, PropertyNotOwned, PropertyTypeMismatch {
final Property<T> property = getByIdAndTypeAndOwner(id, type, owner); final Property<T> property = getByIdAndTypeAndOwner(id, type, owner);
property.update(new State<>(property.getType().cast(value), string)); property.update(new State<>(type, property.getType().cast(value), string));
return property;
} }
@Nullable @Nullable

Some files were not shown because too many files have changed in this diff Show More