angular ui: KnxGroupList, DeviceList
This commit is contained in:
parent
03631e17c2
commit
201cfd9342
18
src/main/angular/.editorconfig
Normal file
18
src/main/angular/.editorconfig
Normal 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
42
src/main/angular/.gitignore
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
27
src/main/angular/README.md
Normal file
27
src/main/angular/README.md
Normal file
@ -0,0 +1,27 @@
|
||||
# Angular
|
||||
|
||||
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.2.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.
|
||||
130
src/main/angular/angular.json
Normal file
130
src/main/angular/angular.json
Normal file
@ -0,0 +1,130 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"angular": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "less",
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:class": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"skipTests": true
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/angular",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "less",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.less"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "2kB",
|
||||
"maximumError": "4kB"
|
||||
}
|
||||
],
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "angular:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "angular:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"inlineStyleLanguage": "less",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.less"
|
||||
],
|
||||
"scripts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14182
src/main/angular/package-lock.json
generated
Normal file
14182
src/main/angular/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
src/main/angular/package.json
Normal file
39
src/main/angular/package.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "angular",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^18.2.0",
|
||||
"@angular/common": "^18.2.0",
|
||||
"@angular/compiler": "^18.2.0",
|
||||
"@angular/core": "^18.2.0",
|
||||
"@angular/forms": "^18.2.0",
|
||||
"@angular/platform-browser": "^18.2.0",
|
||||
"@angular/platform-browser-dynamic": "^18.2.0",
|
||||
"@angular/router": "^18.2.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.14.10",
|
||||
"@stomp/ng2-stompjs": "^8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^18.2.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"
|
||||
}
|
||||
}
|
||||
BIN
src/main/angular/public/favicon.ico
Normal file
BIN
src/main/angular/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
25
src/main/angular/public/switchOff.svg
Normal file
25
src/main/angular/public/switchOff.svg
Normal 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 |
25
src/main/angular/public/switchOn.svg
Normal file
25
src/main/angular/public/switchOn.svg
Normal 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 |
30
src/main/angular/src/app/api/Device/Device.ts
Normal file
30
src/main/angular/src/app/api/Device/Device.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
5
src/main/angular/src/app/api/Device/DeviceFilter.ts
Normal file
5
src/main/angular/src/app/api/Device/DeviceFilter.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export class DeviceFilter {
|
||||
|
||||
search: string = "";
|
||||
|
||||
}
|
||||
32
src/main/angular/src/app/api/Device/device.service.ts
Normal file
32
src/main/angular/src/app/api/Device/device.service.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
31
src/main/angular/src/app/api/Group/Group.ts
Normal file
31
src/main/angular/src/app/api/Group/Group.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import {orNull, 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,
|
||||
) {
|
||||
// -
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
static trackBy(index: number, group: Group) {
|
||||
return group.address;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
5
src/main/angular/src/app/api/Group/GroupFilter.ts
Normal file
5
src/main/angular/src/app/api/Group/GroupFilter.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export class GroupFilter {
|
||||
|
||||
search: string = "";
|
||||
|
||||
}
|
||||
28
src/main/angular/src/app/api/Group/group.service.ts
Normal file
28
src/main/angular/src/app/api/Group/group.service.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
24
src/main/angular/src/app/api/Property/Property.ts
Normal file
24
src/main/angular/src/app/api/Property/Property.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import {State} from "../State/State";
|
||||
import {orNull, validateString} from "../common/validators";
|
||||
|
||||
export class Property {
|
||||
|
||||
constructor(
|
||||
readonly id: string,
|
||||
readonly type: string,
|
||||
readonly lastState: State | null,
|
||||
readonly state: State | null,
|
||||
) {
|
||||
// -
|
||||
}
|
||||
|
||||
static fromJson(json: any): Property {
|
||||
return new Property(
|
||||
validateString(json.id),
|
||||
validateString(json.type),
|
||||
orNull(json.lastState, State.fromJson),
|
||||
orNull(json.state, State.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
23
src/main/angular/src/app/api/State/State.ts
Normal file
23
src/main/angular/src/app/api/State/State.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
4
src/main/angular/src/app/api/common/CrudDirection.ts
Normal file
4
src/main/angular/src/app/api/common/CrudDirection.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum CrudDirection {
|
||||
ASC = 'ASC',
|
||||
DESC = 'DESC',
|
||||
}
|
||||
52
src/main/angular/src/app/api/common/CrudService.ts
Normal file
52
src/main/angular/src/app/api/common/CrudService.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
13
src/main/angular/src/app/api/common/Order.ts
Normal file
13
src/main/angular/src/app/api/common/Order.ts
Normal 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,
|
||||
) {
|
||||
// -
|
||||
}
|
||||
|
||||
}
|
||||
28
src/main/angular/src/app/api/common/Page.ts
Normal file
28
src/main/angular/src/app/api/common/Page.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import {validateList, validateNumber} from "./validators";
|
||||
import {FromJson} from "./types";
|
||||
|
||||
export class Page<T> {
|
||||
|
||||
static readonly EMPTY: Page<any> = new Page(0, 0, 0, 0, []);
|
||||
|
||||
constructor(
|
||||
readonly size: number,
|
||||
readonly number: number,
|
||||
readonly totalPages: number,
|
||||
readonly totalElements: number,
|
||||
readonly content: T[],
|
||||
) {
|
||||
// -
|
||||
}
|
||||
|
||||
static fromJson<T>(fromJson: FromJson<T>): FromJson<Page<T>> {
|
||||
return (json: any) => new Page<T>(
|
||||
validateNumber(json.page.size),
|
||||
validateNumber(json.page.number),
|
||||
validateNumber(json.page.totalPages),
|
||||
validateNumber(json.page.totalElements),
|
||||
validateList(json.content, fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
65
src/main/angular/src/app/api/common/Sort.ts
Normal file
65
src/main/angular/src/app/api/common/Sort.ts
Normal 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 = [];
|
||||
}
|
||||
|
||||
}
|
||||
79
src/main/angular/src/app/api/common/api.service.ts
Normal file
79
src/main/angular/src/app/api/common/api.service.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
40
src/main/angular/src/app/api/common/relative.pipe.ts
Normal file
40
src/main/angular/src/app/api/common/relative.pipe.ts
Normal 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 jetzt'): 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'}`
|
||||
}
|
||||
|
||||
}
|
||||
9
src/main/angular/src/app/api/common/types.ts
Normal file
9
src/main/angular/src/app/api/common/types.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import {environment} from "../../../environments/environment";
|
||||
|
||||
export type FromJson<T> = (json: any) => T;
|
||||
|
||||
export type Next<T> = (item: T) => void;
|
||||
|
||||
export function getApiUrl(protocol: string, path: any[]): string {
|
||||
return protocol + (environment.secure ? 's' : '') + '://' + environment.host + ':' + environment.port + '/' + environment.base + path.join('/');
|
||||
}
|
||||
80
src/main/angular/src/app/api/common/validators.ts
Normal file
80
src/main/angular/src/app/api/common/validators.ts
Normal 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);
|
||||
}
|
||||
17
src/main/angular/src/app/api/common/ws.ts
Normal file
17
src/main/angular/src/app/api/common/ws.ts
Normal 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;
|
||||
}
|
||||
15
src/main/angular/src/app/app.component.html
Normal file
15
src/main/angular/src/app/app.component.html
Normal file
@ -0,0 +1,15 @@
|
||||
<div class="flexBox">
|
||||
<div class="flexBoxFixed menu">
|
||||
<div class="item itemLeft" routerLink="DeviceList" routerLinkActive="active">Geräte</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>
|
||||
43
src/main/angular/src/app/app.component.less
Normal file
43
src/main/angular/src/app/app.component.less
Normal 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;
|
||||
}
|
||||
}
|
||||
22
src/main/angular/src/app/app.component.ts
Normal file
22
src/main/angular/src/app/app.component.ts
Normal 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,
|
||||
) {
|
||||
// -
|
||||
}
|
||||
|
||||
}
|
||||
22
src/main/angular/src/app/app.config.ts
Normal file
22
src/main/angular/src/app/app.config.ts
Normal 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},
|
||||
]
|
||||
};
|
||||
9
src/main/angular/src/app/app.routes.ts
Normal file
9
src/main/angular/src/app/app.routes.ts
Normal file
@ -0,0 +1,9 @@
|
||||
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';
|
||||
|
||||
export const routes: Routes = [
|
||||
{path: 'DeviceList', component: DeviceListPageComponent},
|
||||
{path: 'GroupList', component: KnxGroupListPageComponent},
|
||||
{path: '**', redirectTo: 'GroupList'},
|
||||
];
|
||||
@ -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>
|
||||
@ -0,0 +1,5 @@
|
||||
@import "../../../config";
|
||||
|
||||
input {
|
||||
border-bottom: @border solid lightgray;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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>
|
||||
@ -0,0 +1,5 @@
|
||||
@import "../../../config";
|
||||
|
||||
input {
|
||||
border-bottom: @border solid lightgray;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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">
|
||||
{{ device.stateProperty?.state?.timestamp | relative:now }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@ -0,0 +1,57 @@
|
||||
@import "../../../config";
|
||||
|
||||
.deviceList {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
|
||||
.device {
|
||||
|
||||
.name {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
clear: left;
|
||||
float: left;
|
||||
font-size: 80%;
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.stateOn {
|
||||
background-color: palegreen;
|
||||
}
|
||||
|
||||
.stateOff {
|
||||
background-color: indianred;
|
||||
|
||||
.timestamp {
|
||||
color: lightgray;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@ -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.state?.timestamp | relative:now }}:
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@ -0,0 +1,43 @@
|
||||
@import "../../../config";
|
||||
|
||||
.groupList {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
|
||||
.group {
|
||||
|
||||
.name {
|
||||
margin-bottom: @space;
|
||||
}
|
||||
|
||||
.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: lightgray;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
82
src/main/angular/src/config.less
Normal file
82
src/main/angular/src/config.less
Normal file
@ -0,0 +1,82 @@
|
||||
@space: 0.4em;
|
||||
@border: 0.1em;
|
||||
|
||||
.flexBox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.flexBoxFixed {
|
||||
|
||||
}
|
||||
|
||||
.flexBoxRest {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
7
src/main/angular/src/environments/environment.prod.ts
Normal file
7
src/main/angular/src/environments/environment.prod.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
host: window.location.host.split(":")[0],
|
||||
port: window.location.port,
|
||||
base: 'Home/',
|
||||
secure: window.location.protocol === "https:",
|
||||
};
|
||||
8
src/main/angular/src/environments/environment.ts
Normal file
8
src/main/angular/src/environments/environment.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
host: window.location.host.split(":")[0],
|
||||
port: 8080,
|
||||
base: '',
|
||||
secure: window.location.protocol === "https:",
|
||||
};
|
||||
|
||||
14
src/main/angular/src/index.html
Normal file
14
src/main/angular/src/index.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="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>
|
||||
6
src/main/angular/src/main.ts
Normal file
6
src/main/angular/src/main.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
33
src/main/angular/src/styles.less
Normal file
33
src/main/angular/src/styles.less
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
15
src/main/angular/tsconfig.app.json
Normal file
15
src/main/angular/tsconfig.app.json
Normal file
@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": [
|
||||
"src/main.ts"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
33
src/main/angular/tsconfig.json
Normal file
33
src/main/angular/tsconfig.json
Normal file
@ -0,0 +1,33 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/out-tsc",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "bundler",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"dom"
|
||||
]
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
15
src/main/angular/tsconfig.spec.json
Normal file
15
src/main/angular/tsconfig.spec.json
Normal file
@ -0,0 +1,15 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
16
src/main/java/de/ph87/home/common/AbstractSearchFilter.java
Normal file
16
src/main/java/de/ph87/home/common/AbstractSearchFilter.java
Normal file
@ -0,0 +1,16 @@
|
||||
package de.ph87.home.common;
|
||||
|
||||
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;
|
||||
|
||||
}
|
||||
@ -1,13 +1,13 @@
|
||||
package de.ph87.home.device;
|
||||
package de.ph87.home.common;
|
||||
|
||||
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 class EntityNotFound extends RuntimeException {
|
||||
|
||||
public DeviceNotFound(@NonNull final String key, final @NonNull String value) {
|
||||
public EntityNotFound(@NonNull final String key, @NonNull final Object value) {
|
||||
super("Device not found: %s=%s".formatted(key, value));
|
||||
}
|
||||
|
||||
34
src/main/java/de/ph87/home/common/ISearch.java
Normal file
34
src/main/java/de/ph87/home/common/ISearch.java
Normal file
@ -0,0 +1,34 @@
|
||||
package de.ph87.home.common;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
}
|
||||
@ -23,14 +23,17 @@ public class DemoService {
|
||||
|
||||
@EventListener(ApplicationStartedEvent.class)
|
||||
public void startup() {
|
||||
knxPropertyService.create("eg_ambiente", KnxPropertyType.BOOLEAN, adr(849), adr(848));
|
||||
knxPropertyService.create("fernseher", KnxPropertyType.BOOLEAN, adr(20), adr(4));
|
||||
knxPropertyService.create("verstaerker", KnxPropertyType.BOOLEAN, adr(825), adr(824));
|
||||
knxPropertyService.create("fensterdeko", KnxPropertyType.BOOLEAN, adr(1823), adr(1822));
|
||||
knxPropertyService.create("haengelampe", KnxPropertyType.BOOLEAN, adr(1794), adr(1799));
|
||||
knxPropertyService.create("receiver", KnxPropertyType.BOOLEAN, adr(2561), adr(2560));
|
||||
|
||||
deviceService.create("EG Ambiente", "eg_ambiente", "eg_ambiente");
|
||||
deviceService.create("Wohnzimmer Fernseher", "fernseher", "fernseher");
|
||||
deviceService.create("Wohnzimmer Verstärker", "verstaerker", "verstaerker");
|
||||
deviceService.create("Wohnzimmer Fensterdeko", "fensterdeko", "fensterdeko");
|
||||
deviceService.create("Wohnzimmer Fenster", "fensterdeko", "fensterdeko");
|
||||
deviceService.create("Wohnzimmer Hängelampe", "haengelampe", "haengelampe");
|
||||
deviceService.create("Receiver", "receiver", "receiver");
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import lombok.NonNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import tuwien.auto.calimero.KNXFormatException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -20,6 +21,14 @@ public class DeviceController {
|
||||
|
||||
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
|
||||
@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 {
|
||||
@ -29,20 +38,20 @@ public class DeviceController {
|
||||
|
||||
@NonNull
|
||||
@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());
|
||||
return deviceService.toDto(uuidOrSlug);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@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());
|
||||
return deviceService.toDto(uuidOrSlug).getStateValue();
|
||||
}
|
||||
|
||||
@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());
|
||||
deviceService.setState(uuidOrSlug, state);
|
||||
}
|
||||
|
||||
@ -2,14 +2,19 @@ package de.ph87.home.device;
|
||||
|
||||
import de.ph87.home.property.PropertyDto;
|
||||
import de.ph87.home.property.PropertyTypeMismatch;
|
||||
import de.ph87.home.web.IWebSocketMessage;
|
||||
import jakarta.annotation.Nullable;
|
||||
import lombok.Getter;
|
||||
import lombok.NonNull;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
@ToString
|
||||
public class DeviceDto {
|
||||
public class DeviceDto implements IWebSocketMessage {
|
||||
|
||||
private final List<Object> websocketTopic = List.of("Device");
|
||||
|
||||
@NonNull
|
||||
private final String uuid;
|
||||
@ -21,18 +26,18 @@ public class DeviceDto {
|
||||
private final String slug;
|
||||
|
||||
@NonNull
|
||||
private final String stateConfig;
|
||||
private final String statePropertyId;
|
||||
|
||||
@Nullable
|
||||
@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.name = device.getName();
|
||||
this.slug = device.getSlug();
|
||||
this.stateConfig = device.getStateProperty();
|
||||
this.state = state;
|
||||
this.statePropertyId = device.getStateProperty();
|
||||
this.stateProperty = stateProperty;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@ -47,10 +52,10 @@ public class DeviceDto {
|
||||
|
||||
@Nullable
|
||||
public Boolean getStateValue() throws PropertyTypeMismatch {
|
||||
if (state == null) {
|
||||
if (stateProperty == null) {
|
||||
return null;
|
||||
}
|
||||
return state.getStateValueAs(Boolean.class);
|
||||
return stateProperty.getStateValueAs(Boolean.class);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package de.ph87.home.device;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import de.ph87.home.common.AbstractSearchFilter;
|
||||
import de.ph87.home.property.PropertyTypeMismatch;
|
||||
import jakarta.annotation.Nullable;
|
||||
import lombok.Getter;
|
||||
@ -9,7 +10,7 @@ import lombok.ToString;
|
||||
|
||||
@Getter
|
||||
@ToString
|
||||
public class DeviceFilter {
|
||||
public class DeviceFilter extends AbstractSearchFilter {
|
||||
|
||||
@Nullable
|
||||
@JsonProperty
|
||||
@ -23,19 +24,18 @@ public class DeviceFilter {
|
||||
@JsonProperty
|
||||
private Boolean stateFalse;
|
||||
|
||||
@SuppressWarnings("RedundantIfStatement")
|
||||
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;
|
||||
}
|
||||
final Boolean value = dto.getStateValue();
|
||||
if (stateTrue != null && (dto.getState() == null || stateTrue != value)) {
|
||||
if (stateTrue != null && (dto.getStateProperty() == null || stateTrue != value)) {
|
||||
return false;
|
||||
}
|
||||
if (stateFalse != null && (dto.getState() == null || stateFalse == value)) {
|
||||
if (stateFalse != null && (dto.getStateProperty() == null || stateFalse == value)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return search(dto.getName());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package de.ph87.home.device;
|
||||
|
||||
import de.ph87.home.common.EntityNotFound;
|
||||
import de.ph87.home.property.*;
|
||||
import jakarta.annotation.Nullable;
|
||||
import lombok.NonNull;
|
||||
@ -30,14 +31,14 @@ public class DeviceService {
|
||||
return toDto(deviceRepository.save(new Device(name, slug, stateProperty)));
|
||||
}
|
||||
|
||||
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);
|
||||
final Device device = getByUuidOrSlug(uuidOrSlug);
|
||||
propertyService.write(device.getStateProperty(), state, Boolean.class);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public DeviceDto toDto(final @NonNull String uuidOrSlug) throws DeviceNotFound {
|
||||
public DeviceDto toDto(final @NonNull String uuidOrSlug) {
|
||||
return toDto(getByUuidOrSlug(uuidOrSlug));
|
||||
}
|
||||
|
||||
@ -48,8 +49,13 @@ public class DeviceService {
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private Device getByUuidOrSlug(@NonNull final String uuidOrSlug) throws DeviceNotFound {
|
||||
return deviceRepository.findByUuidOrSlug(uuidOrSlug, uuidOrSlug).orElseThrow(() -> new DeviceNotFound("uuidOrSlug", uuidOrSlug));
|
||||
private Device getByUuidOrSlug(@NonNull final String uuidOrSlug) {
|
||||
return deviceRepository.findByUuidOrSlug(uuidOrSlug, uuidOrSlug).orElseThrow(() -> new EntityNotFound("uuidOrSlug", uuidOrSlug));
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private Device getByUuid(@NonNull final String uuid) {
|
||||
return deviceRepository.findById(uuid).orElseThrow(() -> new EntityNotFound("uuid", uuid));
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@ -78,4 +84,9 @@ public class DeviceService {
|
||||
applicationEventPublisher.publishEvent(deviceDto);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public DeviceDto getByUuidDto(@NonNull final String uuid) {
|
||||
return toDto(getByUuid(uuid));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
package de.ph87.home.knx;
|
||||
package de.ph87.home.knx.group;
|
||||
|
||||
import de.ph87.home.knx.DPT;
|
||||
import de.ph87.home.property.State;
|
||||
import jakarta.annotation.Nullable;
|
||||
import lombok.Getter;
|
||||
import lombok.NonNull;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
import tuwien.auto.calimero.GroupAddress;
|
||||
|
||||
@ -31,6 +35,10 @@ public class Group {
|
||||
@ToString.Exclude
|
||||
private long puid;
|
||||
|
||||
@Setter
|
||||
@Nullable
|
||||
private State<?> state;
|
||||
|
||||
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.address = address;
|
||||
@ -1,4 +1,4 @@
|
||||
package de.ph87.home.knx;
|
||||
package de.ph87.home.knx.group;
|
||||
|
||||
import io.micrometer.common.lang.Nullable;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
37
src/main/java/de/ph87/home/knx/group/GroupController.java
Normal file
37
src/main/java/de/ph87/home/knx/group/GroupController.java
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
53
src/main/java/de/ph87/home/knx/group/GroupDto.java
Normal file
53
src/main/java/de/ph87/home/knx/group/GroupDto.java
Normal file
@ -0,0 +1,53 @@
|
||||
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.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
@ToString
|
||||
public class GroupDto implements IWebSocketMessage {
|
||||
|
||||
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;
|
||||
|
||||
@Setter
|
||||
@Nullable
|
||||
private State<?> state;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
16
src/main/java/de/ph87/home/knx/group/GroupFilter.java
Normal file
16
src/main/java/de/ph87/home/knx/group/GroupFilter.java
Normal file
@ -0,0 +1,16 @@
|
||||
package de.ph87.home.knx.group;
|
||||
|
||||
import de.ph87.home.common.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());
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package de.ph87.home.knx;
|
||||
package de.ph87.home.knx.group;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
@ -1,5 +1,8 @@
|
||||
package de.ph87.home.knx;
|
||||
package de.ph87.home.knx.group;
|
||||
|
||||
import de.ph87.home.knx.DPT;
|
||||
import de.ph87.home.knx.DPTException;
|
||||
import jakarta.annotation.Nullable;
|
||||
import lombok.NonNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@ -23,7 +26,7 @@ import java.util.Optional;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class GroupService {
|
||||
public class GroupRepository {
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
54
src/main/java/de/ph87/home/knx/group/GroupService.java
Normal file
54
src/main/java/de/ph87/home/knx/group/GroupService.java
Normal file
@ -0,0 +1,54 @@
|
||||
package de.ph87.home.knx.group;
|
||||
|
||||
import de.ph87.home.common.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));
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
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.link.request.Request;
|
||||
import de.ph87.home.knx.link.request.RequestRead;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
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.NonNull;
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
package de.ph87.home.knx.link.request;
|
||||
|
||||
import de.ph87.home.knx.Group;
|
||||
import de.ph87.home.knx.group.Group;
|
||||
import lombok.NonNull;
|
||||
|
||||
public class RequestRead extends Request {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
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.NonNull;
|
||||
import tuwien.auto.calimero.dptxlator.DPTXlator;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
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.persistence.Column;
|
||||
import jakarta.persistence.Convert;
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
package de.ph87.home.knx.property;
|
||||
|
||||
import de.ph87.home.knx.Group;
|
||||
import de.ph87.home.knx.GroupLoaded;
|
||||
import de.ph87.home.knx.GroupService;
|
||||
import de.ph87.home.knx.group.Group;
|
||||
import de.ph87.home.knx.group.GroupLoaded;
|
||||
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.property.PropertyNotFound;
|
||||
import de.ph87.home.property.PropertyNotOwned;
|
||||
import de.ph87.home.property.PropertyService;
|
||||
import de.ph87.home.property.PropertyTypeMismatch;
|
||||
import de.ph87.home.property.*;
|
||||
import jakarta.annotation.Nullable;
|
||||
import lombok.NonNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@ -38,10 +36,12 @@ public class KnxPropertyService {
|
||||
|
||||
private final PropertyService propertyService;
|
||||
|
||||
private final GroupService groupService;
|
||||
private final GroupRepository groupRepository;
|
||||
|
||||
private final KnxLinkService knxLinkService;
|
||||
|
||||
private final GroupService groupService;
|
||||
|
||||
@Scheduled(initialDelay = 1, fixedDelay = 1, timeUnit = TimeUnit.HOURS)
|
||||
public void readAll() {
|
||||
knxPropertyRepository.findAll().forEach(this::read);
|
||||
@ -78,7 +78,7 @@ public class KnxPropertyService {
|
||||
|
||||
private void onProcessEvent(@NonNull final KnxProperty knxProperty, @NonNull final ProcessEvent 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) {
|
||||
@ -87,15 +87,17 @@ public class KnxPropertyService {
|
||||
final DPTXlator translator = group.getDpt().createTranslator();
|
||||
translator.setData(event.getASDU());
|
||||
log.debug("translator: {}", translator);
|
||||
switch (knxProperty.getType()) {
|
||||
final Property<?> property = switch (knxProperty.getType()) {
|
||||
case BOOLEAN -> {
|
||||
if (!(translator instanceof final DPTXlatorBoolean booleanTranslator)) {
|
||||
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());
|
||||
}
|
||||
};
|
||||
group.setState(property.getState());
|
||||
groupService.publish(group);
|
||||
} catch (KNXException | PropertyNotFound | PropertyTypeMismatch | PropertyNotOwned e) {
|
||||
log.error("Failed to handle ProcessEvent: knxProperty={}, error={}", knxProperty, e.toString());
|
||||
}
|
||||
@ -104,12 +106,12 @@ public class KnxPropertyService {
|
||||
private void read(@NonNull final KnxProperty knxProperty) {
|
||||
final Optional<KnxProperty> property = knxPropertyRepository.findById(knxProperty.getId());
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
20
src/main/java/de/ph87/home/property/ClassSerializer.java
Normal file
20
src/main/java/de/ph87/home/property/ClassSerializer.java
Normal file
@ -0,0 +1,20 @@
|
||||
package de.ph87.home.property;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class ClassSerializer 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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -104,9 +104,10 @@ public class PropertyService {
|
||||
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);
|
||||
property.update(new State<>(property.getType().cast(value), string));
|
||||
property.update(new State<>(type, property.getType().cast(value), string));
|
||||
return property;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package de.ph87.home.property;
|
||||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import jakarta.annotation.Nullable;
|
||||
import lombok.Getter;
|
||||
import lombok.NonNull;
|
||||
@ -11,6 +12,10 @@ import java.time.ZonedDateTime;
|
||||
@ToString
|
||||
public class State<T> {
|
||||
|
||||
@NonNull
|
||||
@JsonSerialize(using = ClassSerializer.class)
|
||||
private final Class<T> type;
|
||||
|
||||
@NonNull
|
||||
private final ZonedDateTime timestamp;
|
||||
|
||||
@ -20,10 +25,11 @@ public class State<T> {
|
||||
@Nullable
|
||||
private final String string;
|
||||
|
||||
public State(@Nullable final T value, @Nullable final String string) {
|
||||
this.string = string;
|
||||
public State(@NonNull final Class<T> type, @Nullable final T value, @Nullable final String string) {
|
||||
this.type = type;
|
||||
this.timestamp = ZonedDateTime.now();
|
||||
this.value = value;
|
||||
this.string = string;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
12
src/main/java/de/ph87/home/web/IWebSocketMessage.java
Normal file
12
src/main/java/de/ph87/home/web/IWebSocketMessage.java
Normal file
@ -0,0 +1,12 @@
|
||||
package de.ph87.home.web;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface IWebSocketMessage {
|
||||
|
||||
@JsonIgnore
|
||||
List<Object> getWebsocketTopic();
|
||||
|
||||
}
|
||||
28
src/main/java/de/ph87/home/web/SubscriptionInterceptor.java
Normal file
28
src/main/java/de/ph87/home/web/SubscriptionInterceptor.java
Normal file
@ -0,0 +1,28 @@
|
||||
package de.ph87.home.web;
|
||||
|
||||
import lombok.NonNull;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.messaging.Message;
|
||||
import org.springframework.messaging.MessageChannel;
|
||||
import org.springframework.messaging.simp.stomp.StompCommand;
|
||||
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
|
||||
import org.springframework.messaging.support.ChannelInterceptor;
|
||||
import org.springframework.messaging.support.MessageHeaderAccessor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class SubscriptionInterceptor implements ChannelInterceptor {
|
||||
|
||||
@Override
|
||||
public Message<?> preSend(@NonNull final Message<?> message, @NonNull final MessageChannel channel) {
|
||||
final StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
|
||||
if (accessor != null && StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
|
||||
final String sessionId = accessor.getSessionId();
|
||||
final String destination = accessor.getDestination();
|
||||
log.info("User subscribed with sessionId: {} to destination: {}", sessionId, destination);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
}
|
||||
50
src/main/java/de/ph87/home/web/WebConfig.java
Normal file
50
src/main/java/de/ph87/home/web/WebConfig.java
Normal file
@ -0,0 +1,50 @@
|
||||
package de.ph87.home.web;
|
||||
|
||||
import lombok.NonNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.data.web.config.EnableSpringDataWebSupport;
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import static org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addArgumentResolvers(@NonNull List<HandlerMethodArgumentResolver> argumentResolvers) {
|
||||
// argumentResolvers.add(new UserPrivateUuidArgumentResolver());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/**").allowCredentials(true).allowedOriginPatterns("*").allowedMethods("*");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(final ResourceHandlerRegistry registry) {
|
||||
registry
|
||||
.addResourceHandler("/**")
|
||||
.addResourceLocations("classpath:/resources/")
|
||||
.resourceChain(true)
|
||||
.addResolver(new PathResourceResolver() {
|
||||
|
||||
protected Resource getResource(@NonNull String resourcePath, @NonNull Resource roomLocation) throws IOException {
|
||||
final Resource requestedResource = roomLocation.createRelative(resourcePath);
|
||||
return requestedResource.exists() && requestedResource.isReadable() ? requestedResource : new ClassPathResource("/resources/index.html");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
35
src/main/java/de/ph87/home/web/WebSocketConfig.java
Normal file
35
src/main/java/de/ph87/home/web/WebSocketConfig.java
Normal file
@ -0,0 +1,35 @@
|
||||
package de.ph87.home.web;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
||||
import org.springframework.scheduling.TaskScheduler;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
|
||||
import org.springframework.web.bind.annotation.CrossOrigin;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
||||
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
||||
|
||||
@CrossOrigin
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
public static final String DESTINATION = "";
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
registry.addEndpoint("/ws").setAllowedOrigins("*");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||||
config.enableSimpleBroker(DESTINATION).setHeartbeatValue(new long[]{2000, 2000}).setTaskScheduler(heartBeatScheduler());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public TaskScheduler heartBeatScheduler() {
|
||||
return new ThreadPoolTaskScheduler();
|
||||
}
|
||||
|
||||
}
|
||||
30
src/main/java/de/ph87/home/web/WebSocketService.java
Normal file
30
src/main/java/de/ph87/home/web/WebSocketService.java
Normal file
@ -0,0 +1,30 @@
|
||||
package de.ph87.home.web;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.messaging.simp.SimpMessageSendingOperations;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WebSocketService {
|
||||
|
||||
private final SimpMessageSendingOperations simpMessageSendingOperations;
|
||||
|
||||
@EventListener(IWebSocketMessage.class)
|
||||
public void send(@NonNull final IWebSocketMessage message) {
|
||||
send(message.getWebsocketTopic(), message);
|
||||
}
|
||||
|
||||
public void send(@NonNull final List<Object> topic, @NonNull final Object message) {
|
||||
final String topicString = topic.stream().map(Object::toString).collect(Collectors.joining("/"));
|
||||
simpMessageSendingOperations.convertAndSend(topicString, message);
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user