Tag, Taggable

This commit is contained in:
Patrick Haßel 2024-11-28 14:37:22 +01:00
parent ad130fc35e
commit 61ffab50ba
81 changed files with 1035 additions and 425 deletions

View File

@ -44,10 +44,6 @@ export class Device {
return device.uuid;
}
static equals(a: Device, b: Device): boolean {
return a.uuid === b.uuid;
}
static compareByAreaThenName(a: Device, b: Device): number {
const area = Area.compareByName(a.area, b.area);
if (area !== 0) {

View File

@ -4,8 +4,6 @@ import {Device} from './Device';
import {ApiService} from '../common/api.service';
import {Next} from '../common/types';
import {DeviceFilter} from './DeviceFilter';
@Injectable({
providedIn: 'root'
})
@ -21,10 +19,6 @@ export class DeviceService extends CrudService<Device> {
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

@ -4,8 +4,6 @@ import {Group} from './Group';
import {ApiService} from '../common/api.service';
import {Next} from '../common/types';
import {GroupFilter} from './GroupFilter';
@Injectable({
providedIn: 'root'
})
@ -21,8 +19,4 @@ export class GroupService extends CrudService<Group> {
this.getSingle(['getByAddress', address], next);
}
list(filter: GroupFilter | null, next: Next<Group[]>): void {
this.postList(['list'], filter, next);
}
}

View File

@ -45,10 +45,6 @@ export class Shutter {
return shutter.uuid;
}
static equals(a: Shutter, b: Shutter): boolean {
return a.uuid === b.uuid;
}
static compareByAreaThenName(a: Shutter, b: Shutter): number {
const area = Area.compareByName(a.area, b.area);
if (area !== 0) {

View File

@ -4,8 +4,6 @@ import {Shutter} from './Shutter';
import {ApiService} from '../common/api.service';
import {Next} from '../common/types';
import {ShutterFilter} from './ShutterFilter';
@Injectable({
providedIn: 'root'
})
@ -21,10 +19,6 @@ export class ShutterService extends CrudService<Shutter> {
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,19 @@
import {validateString} from '../common/validators';
export class Tag {
constructor(
readonly uuid: string,
readonly name: string,
) {
//
}
static fromJson(json: any): Tag {
return new Tag(
validateString(json.uuid),
validateString(json.name),
);
}
}

View File

@ -0,0 +1,17 @@
import {Injectable} from '@angular/core';
import {ApiService} from '../common/api.service';
import {CrudService} from '../common/CrudService';
import {Tag} from './Tag';
@Injectable({
providedIn: 'root'
})
export class TagService extends CrudService<Tag> {
constructor(
apiService: ApiService,
) {
super(apiService, ['Tag'], Tag.fromJson);
}
}

View File

@ -0,0 +1,19 @@
import {Device} from "../Device/Device";
import {Shutter} from "../Shutter/Shutter";
import {Tunable} from "../Tunable/Tunable";
import {validateAndRemoveDtoSuffix} from "../common/validators";
export type Taggable = Device | Shutter | Tunable;
export function taggableFromJson(json: any): Taggable {
const _type_ = validateAndRemoveDtoSuffix(json._type_);
switch (_type_) {
case 'Device':
return Device.fromJson(json.payload);
case 'Shutter':
return Shutter.fromJson(json.payload);
case 'Tunable':
return Tunable.fromJson(json.payload);
}
throw new Error("Type not implemented: " + _type_);
}

View File

@ -0,0 +1,7 @@
export class TaggableFilter {
tag: string = "";
search: string = "";
}

View File

@ -0,0 +1,33 @@
import {Injectable} from '@angular/core';
import {ApiService} from '../common/api.service';
import {CrudService} from '../common/CrudService';
import {Taggable, taggableFromJson} from './Taggable';
import {Next} from '../common/types';
import {Subject, Subscription} from 'rxjs';
import {DeviceService} from '../Device/device.service';
import {ShutterService} from '../Shutter/shutter.service';
import {TunableService} from '../Tunable/tunable.service';
@Injectable({
providedIn: 'root'
})
export class TaggableService extends CrudService<Taggable> {
constructor(
apiService: ApiService,
protected readonly deviceService: DeviceService,
protected readonly shutterService: ShutterService,
protected readonly tunableService: TunableService,
) {
super(apiService, ['Taggable'], taggableFromJson);
}
override subscribe(next: Next<Taggable>): Subscription {
const subject = new Subject<Taggable>();
this.deviceService.subscribe(next => subject.next(next));
this.shutterService.subscribe(next => subject.next(next));
this.tunableService.subscribe(next => subject.next(next));
return subject.subscribe(next);
}
}

View File

@ -52,10 +52,6 @@ export class Tunable {
return tunable.uuid;
}
static equals(a: Tunable, b: Tunable): boolean {
return a.uuid === b.uuid;
}
static compareByAreaThenName(a: Tunable, b: Tunable): number {
const area = Area.compareByName(a.area, b.area);
if (area !== 0) {

View File

@ -4,8 +4,6 @@ import {Tunable} from './Tunable';
import {ApiService} from '../common/api.service';
import {Next} from '../common/types';
import {TunableFilter} from './TunableFilter';
@Injectable({
providedIn: 'root'
})
@ -21,10 +19,6 @@ export class TunableService extends CrudService<Tunable> {
this.getSingle(['getByUuid', uuid], next);
}
list(filter: TunableFilter | null, next: Next<Tunable[]>): void {
this.postList(['list'], filter, next);
}
setState(tunable: Tunable, state: boolean, next?: Next<void>): void {
this.getNone(['setState', tunable.uuid, state], next);
}

View File

@ -1,46 +1,62 @@
import {CrudService} from "./CrudService";
import {Subscription} from "rxjs";
import {Next} from './types';
export class CrudLiveList<ENTITY> extends Subscription {
export interface UUID {
uuid: string;
}
export class CrudLiveList<ENTITY extends UUID> extends Subscription {
private readonly subs: Subscription[] = [];
unfiltered: ENTITY[] = [];
private unfiltered: ENTITY[] = [];
filtered: ENTITY[] = [];
list: ENTITY[] = [];
constructor(
readonly crudService: CrudService<ENTITY>,
readonly equals: (a: ENTITY, b: ENTITY) => boolean,
readonly allowAppending: boolean,
readonly filter: (item: ENTITY) => boolean = _ => true,
readonly all: (next: Next<ENTITY[]>) => any = next => this.crudService.list(next),
readonly equals: (a: ENTITY, b: ENTITY) => boolean = (a, b) => a.uuid === b.uuid,
) {
super(() => {
this.subs.forEach(sub => sub.unsubscribe());
});
this.fetchAll();
this.subs.push(crudService.api.connected(_ => this.fetchAll()));
this.subs.push(crudService.api.connected(_ => this.refresh()));
this.subs.push(crudService.subscribe(item => this.update(item)));
}
private fetchAll() {
this.crudService.all(list => {
refresh() {
this.all(list => {
this.unfiltered = list;
this.updateFiltered();
});
}
clear() {
this.unfiltered = [];
this.updateFiltered();
}
private update(item: ENTITY) {
const index = this.unfiltered.findIndex(i => this.equals(i, item));
if (index >= 0) {
this.unfiltered[index] = item;
} else {
if (!this.allowAppending) {
return;
}
this.unfiltered.push(item);
}
this.updateFiltered();
}
private updateFiltered() {
this.filtered = this.unfiltered.filter(this.filter);
this.list = this.unfiltered.filter(this.filter);
}
}

View File

@ -13,10 +13,14 @@ export abstract class CrudService<ENTITY> {
//
}
all(next: Next<ENTITY[]>) {
list(next: Next<ENTITY[]>): void {
this.getList(['list'], next);
}
filter<FILTER>(filter: FILTER, next: Next<ENTITY[]>): void {
this.postList(['list'], filter, next);
}
subscribe(next: Next<ENTITY>): Subscription {
return this.api.subscribe([...this.path], this.fromJson, next);
}

View File

@ -79,6 +79,14 @@ export function orNull<T, R>(item: T | null | undefined, map: (t: T) => R): R |
return map(item);
}
export function validateAndRemoveDtoSuffix(json: any): string {
const type = validateString(json);
if (!type.endsWith('Dto')) {
throw Error("Type name does not end with Dto: " + type);
}
return type.substring(0, type.length - 3);
}
export function isSet(value: any) {
return value !== null && value !== undefined;
}

View File

@ -1,9 +1,9 @@
<div class="flexBox">
<div class="flexBoxFixed menu">
<div class="item itemLeft" routerLink="Dashboard" routerLinkActive="active">Dash</div>
<div class="item itemLeft" routerLink="DeviceList" routerLinkActive="active">Geräte</div>
<div class="item itemLeft" routerLink="TunableList" routerLinkActive="active">Lichter</div>
<div class="item itemLeft" routerLink="ShutterList" routerLinkActive="active">Rollläden</div>
<div class="item itemLeft" routerLink="TaggableList/device" routerLinkActive="active">Geräte</div>
<div class="item itemLeft" routerLink="TaggableList/light" routerLinkActive="active">Licht</div>
<div class="item itemLeft" routerLink="TaggableList/shutter" routerLinkActive="active">Rollladen</div>
<div class="item itemRight" routerLink="GroupList" routerLinkActive="active">KNX</div>
</div>
<div class="flexBoxRest">

View File

@ -1,15 +1,12 @@
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';
import {TunableListPageComponent} from './pages/tunable-list-page/tunable-list-page.component';
import {DashboardComponent} from './pages/dashboard/dashboard.component';
import {TaggableListPageComponent} from './pages/taggable-list-page/taggable-list-page.component';
export const routes: Routes = [
{path: 'Dashboard', component: DashboardComponent},
{path: 'DeviceList', component: DeviceListPageComponent},
{path: 'TunableList', component: TunableListPageComponent},
{path: 'ShutterList', component: ShutterListPageComponent},
{path: 'GroupList', component: KnxGroupListPageComponent},
{path: 'TaggableList', component: TaggableListPageComponent},
{path: 'TaggableList/:tag', component: TaggableListPageComponent},
{path: '**', redirectTo: 'Dashboard'},
];

View File

@ -1,6 +1,6 @@
<div class="verticalScroll">
<app-device-list [list]="deviceList.filtered"></app-device-list>
<app-tunable-list [list]="tunableList.filtered"></app-tunable-list>
<app-shutter-list [list]="shutterList.filtered"></app-shutter-list>
<div class="emptyBox" *ngIf="deviceList.filtered.length === 0 && tunableList.filtered.length === 0 && shutterList.filtered.length === 0">- Nichts -</div>
<app-device-list [list]="deviceList.list"></app-device-list>
<app-tunable-list [list]="tunableList.list"></app-tunable-list>
<app-shutter-list [list]="shutterList.list"></app-shutter-list>
<div class="emptyBox" *ngIf="deviceList.list.length === 0 && tunableList.list.length === 0 && shutterList.list.length === 0">- Nichts -</div>
</div>

View File

@ -52,9 +52,9 @@ export class DashboardComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.newDate();
this.subs.push(timer(5000, 5000).subscribe(() => this.newDate()));
this.subs.push(this.deviceList = new CrudLiveList(this.deviceService, Device.equals, device => device.stateProperty?.state?.value === true));
this.subs.push(this.tunableList = new CrudLiveList(this.tunableService, Tunable.equals, tunable => tunable.stateProperty?.state?.value === true));
this.subs.push(this.shutterList = new CrudLiveList(this.shutterService, Shutter.equals, shutter => this.shutterFilter(shutter)));
this.subs.push(this.deviceList = new CrudLiveList(this.deviceService, true, device => device.stateProperty?.state?.value === true));
this.subs.push(this.tunableList = new CrudLiveList(this.tunableService, true, tunable => tunable.stateProperty?.state?.value === true));
this.subs.push(this.shutterList = new CrudLiveList(this.shutterService, true, shutter => this.shutterFilter(shutter)));
}
private newDate() {

View File

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

View File

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

View File

@ -1,68 +0,0 @@
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 {ApiService} from '../../api/common/api.service';
@Component({
selector: 'app-device-list-page',
standalone: true,
imports: [
DeviceListComponent,
FormsModule
],
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

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

View File

@ -6,13 +6,15 @@ import {FormsModule} from '@angular/forms';
import {GroupFilter} from '../../api/Group/GroupFilter';
import {Subscription} from 'rxjs';
import {ApiService} from '../../api/common/api.service';
import {SearchComponent} from '../../shared/search/search.component';
@Component({
selector: 'app-knx-group-list-page',
standalone: true,
imports: [
KnxGroupListComponent,
FormsModule
FormsModule,
SearchComponent
],
templateUrl: './knx-group-list-page.component.html',
styleUrl: './knx-group-list-page.component.less'
@ -25,8 +27,6 @@ export class KnxGroupListPageComponent implements OnInit, OnDestroy {
protected filter: GroupFilter = new GroupFilter();
private fetchTimeout: any;
constructor(
protected readonly groupService: GroupService,
protected readonly apiService: ApiService,
@ -44,16 +44,8 @@ export class KnxGroupListPageComponent implements OnInit, OnDestroy {
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)
protected fetch() {
this.groupService.filter(this.filter, list => this.groupList = list)
}
private updateGroup(group: Group) {

View File

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

View File

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

View File

@ -1,68 +0,0 @@
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 {ApiService} from '../../api/common/api.service';
@Component({
selector: 'app-shutter-list-page',
standalone: true,
imports: [
ShutterListComponent,
FormsModule
],
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,8 @@
<div class="flexBox">
<div class="flexBoxFixed">
<app-search [(search)]="filter.search" (doSearch)="liveList.refresh()"></app-search>
</div>
<div class="flexBoxRest verticalScroll">
<app-taggable-list [list]="liveList.list"></app-taggable-list>
</div>
</div>

View File

@ -0,0 +1,68 @@
import {Component, OnDestroy, OnInit} from '@angular/core';
import {TaggableListComponent} from '../../shared/taggable-list/taggable-list.component';
import {Taggable} from '../../api/Taggable/Taggable';
import {TaggableService} from '../../api/Taggable/taggable.service';
import {FormsModule} from '@angular/forms';
import {Subscription} from 'rxjs';
import {TaggableFilter} from '../../api/Taggable/TaggableFilter';
import {ActivatedRoute} from '@angular/router';
import {CrudLiveList} from '../../api/common/CrudLiveList';
import {SearchComponent} from '../../shared/search/search.component';
@Component({
selector: 'app-taggable-list-page',
standalone: true,
imports: [
TaggableListComponent,
FormsModule,
SearchComponent
],
templateUrl: './taggable-list-page.component.html',
styleUrl: './taggable-list-page.component.less'
})
export class TaggableListPageComponent implements OnInit, OnDestroy {
private readonly subs: Subscription[] = [];
protected readonly filter: TaggableFilter = new TaggableFilter();
protected readonly liveList: CrudLiveList<Taggable>;
private tagSet: boolean = false;
constructor(
protected readonly taggableService: TaggableService,
protected readonly activatedRoute: ActivatedRoute,
) {
this.subs.push(this.liveList = new CrudLiveList(
this.taggableService,
false,
undefined,
next => {
if (this.tagSet) {
this.taggableService.filter(this.filter, next);
} else {
next([]);
}
})
);
}
ngOnInit(): void {
this.subs.push(this.activatedRoute.params.subscribe(params => {
this.tagSet = 'tag' in params;
if (this.tagSet) {
this.filter.tag = params['tag'] || '';
console.log(this.filter.tag);
this.liveList.refresh();
} else {
this.liveList.clear();
}
}));
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
}

View File

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

View File

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

View File

@ -1,68 +0,0 @@
import {Component, OnDestroy, OnInit} from '@angular/core';
import {TunableListComponent} from '../../shared/tunable-list/tunable-list.component';
import {Tunable} from '../../api/Tunable/Tunable';
import {TunableService} from '../../api/Tunable/tunable.service';
import {FormsModule} from '@angular/forms';
import {TunableFilter} from '../../api/Tunable/TunableFilter';
import {Subscription} from 'rxjs';
import {ApiService} from '../../api/common/api.service';
@Component({
selector: 'app-tunable-list-page',
standalone: true,
imports: [
TunableListComponent,
FormsModule
],
templateUrl: './tunable-list-page.component.html',
styleUrl: './tunable-list-page.component.less'
})
export class TunableListPageComponent implements OnInit, OnDestroy {
private readonly subs: Subscription[] = [];
protected tunableList: Tunable[] = [];
protected filter: TunableFilter = new TunableFilter();
private fetchTimeout: any;
constructor(
protected readonly tunableService: TunableService,
protected readonly apiService: ApiService,
) {
//
}
ngOnInit(): void {
this.fetch();
this.apiService.connected(() => this.fetch());
this.subs.push(this.tunableService.subscribe(tunable => this.updateTunable(tunable)));
}
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.tunableService.list(this.filter, list => this.tunableList = list)
}
private updateTunable(tunable: Tunable) {
const index = this.tunableList.findIndex(d => d.uuid === tunable.uuid);
if (index >= 0) {
this.tunableList.splice(index, 1, tunable);
} else {
this.fetch();
}
}
}

View File

@ -3,7 +3,6 @@
.group {
.name {
margin-bottom: @space;
}
}

View File

@ -0,0 +1,3 @@
<div class="box">
<input type="text" [(ngModel)]="search" (ngModelChange)="fetchDelayed()" placeholder="Filter ...">
</div>

View File

@ -0,0 +1,6 @@
@import "../../../config";
.box {
width: 100%;
padding: @space @space 0 @space;
}

View File

@ -0,0 +1,35 @@
import {Component, EventEmitter, Input, Output} from '@angular/core';
import {FormsModule} from '@angular/forms';
@Component({
selector: 'app-search',
standalone: true,
imports: [
FormsModule
],
templateUrl: './search.component.html',
styleUrl: './search.component.less'
})
export class SearchComponent {
@Input()
search: string = '';
@Output()
searchChange: EventEmitter<string> = new EventEmitter();
@Output()
doSearch: EventEmitter<string> = new EventEmitter();
private fetchTimeout: any;
fetchDelayed() {
if (this.fetchTimeout) {
clearTimeout(this.fetchTimeout);
this.fetchTimeout = undefined;
}
this.searchChange.emit(this.search);
this.fetchTimeout = setTimeout(() => this.doSearch.emit(this.search), 300);
}
}

View File

@ -0,0 +1,5 @@
<div class="tileContainer taggableList">
<app-taggable-tile [now]="now" [taggable]="taggable" *ngFor="let taggable of list"></app-taggable-tile>
</div>

View File

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

View File

@ -0,0 +1,35 @@
import {Component, Input, OnDestroy, OnInit} from '@angular/core';
import {NgForOf} from '@angular/common';
import {Subscription, timer} from 'rxjs';
import {TaggableTileComponent} from '../taggable-tile/taggable-tile.component';
import {Taggable} from '../../api/Taggable/Taggable';
@Component({
selector: 'app-taggable-list',
standalone: true,
imports: [
NgForOf,
TaggableTileComponent
],
templateUrl: './taggable-list.component.html',
styleUrl: './taggable-list.component.less'
})
export class TaggableListComponent implements OnInit, OnDestroy {
private readonly subs: Subscription[] = [];
protected now: Date = new Date();
@Input()
list: Taggable[] = [];
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 @@
<app-device-tile *ngIf="isDevice()" [now]="now" [device]="asDevice()"></app-device-tile>
<app-shutter-tile *ngIf="isShutter()" [now]="now" [shutter]="asShutter()"></app-shutter-tile>
<app-tunable-tile *ngIf="isTunable()" [now]="now" [tunable]="asTunable()"></app-tunable-tile>

View File

@ -0,0 +1,55 @@
import {Component, Input} from '@angular/core';
import {Device} from '../../api/Device/Device';
import {Tunable} from '../../api/Tunable/Tunable';
import {Shutter} from '../../api/Shutter/Shutter';
import {DeviceTileComponent} from '../device-tile/device-tile.component';
import {NgIf} from '@angular/common';
import {ShutterTileComponent} from '../shutter-tile/shutter-tile.component';
import {TunableTileComponent} from '../tunable-tile/tunable-tile.component';
import {Taggable} from '../../api/Taggable/Taggable';
@Component({
selector: 'app-taggable-tile',
standalone: true,
imports: [
DeviceTileComponent,
NgIf,
ShutterTileComponent,
TunableTileComponent
],
templateUrl: './taggable-tile.component.html',
styleUrl: './taggable-tile.component.less'
})
export class TaggableTileComponent {
@Input()
now!: Date;
@Input()
taggable!: Taggable;
asDevice(): Device {
return this.taggable as Device;
}
isDevice(): boolean {
return this.taggable instanceof Device;
}
asShutter(): Shutter {
return this.taggable as Shutter;
}
isShutter(): boolean {
return this.taggable instanceof Shutter;
}
asTunable(): Tunable {
return this.taggable as Tunable;
}
isTunable(): boolean {
return this.taggable instanceof Tunable;
}
}

View File

@ -19,9 +19,8 @@ div {
input[type=text] {
all: unset;
width: calc(100% - 2 * 0.2em - @border);
padding-left: 0.2em;
padding-right: 0.2em;
width: 100%;
border: @border solid lightgray;
}
.emptyBox{

View File

@ -1,5 +1,6 @@
package de.ph87.home.area;
import de.ph87.home.search.ISearchable;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@ -8,13 +9,14 @@ import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.ToString;
import java.util.List;
import java.util.UUID;
@Entity
@Getter
@ToString
@NoArgsConstructor
public class Area {
public class Area implements ISearchable {
@Id
@NonNull
@ -28,6 +30,11 @@ public class Area {
@Column(nullable = false, unique = true)
private String slug;
@Override
public List<?> getSearchableValues() {
return List.of(slug, name);
}
public Area(@NonNull final String name, @NonNull final String slug) {
this.name = name;
this.slug = slug;

View File

@ -6,12 +6,14 @@ import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import static de.ph87.home.common.crud.SearchHelper.search;
@Getter
@ToString
public class AreaFilter extends AbstractSearchFilter {
public boolean filter(@NonNull final AreaDto dto) throws PropertyTypeMismatch {
return search(dto.getName());
return search(search, dto.getName());
}
}

View File

@ -0,0 +1,21 @@
package de.ph87.home.common;
import lombok.NonNull;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class ListHelpers {
@NonNull
@SafeVarargs
public static <T> List<T> merge(@NonNull final List<T>... listList) {
final List<T> merged = new ArrayList<>(Arrays.stream(listList).reduce(0, (total, subList) -> total + subList.size(), Integer::sum));
for (final List<T> ts : listList) {
merged.addAll(ts);
}
return merged;
}
}

View File

@ -11,6 +11,6 @@ public abstract class AbstractSearchFilter implements ISearch {
@Nullable
@JsonProperty
private String search;
protected String search;
}

View File

@ -13,22 +13,4 @@ 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,30 @@
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 class SearchHelper {
public static boolean search(@Nullable final String term, @NonNull final Object... haystack) {
if (term == null) {
return true;
}
final List<String> haystackValues = Arrays.stream(haystack).map(Object::toString).map(String::toLowerCase).toList();
return splitWords(term).allMatch(word -> anyMatch(word, haystackValues));
}
@NonNull
public static Stream<String> splitWords(@NonNull final String term) {
return Arrays.stream(term.toLowerCase(Locale.ROOT).replaceAll("^\\s|\\s$", "").split("\\s+"));
}
public static boolean anyMatch(@NonNull final String needle, @NonNull final List<String> haystack) {
return haystack.stream().anyMatch(lowerCaseHayStack -> lowerCaseHayStack.contains(needle));
}
}

View File

@ -6,6 +6,8 @@ import de.ph87.home.device.DeviceService;
import de.ph87.home.knx.property.KnxPropertyService;
import de.ph87.home.knx.property.KnxPropertyType;
import de.ph87.home.shutter.ShutterService;
import de.ph87.home.tag.TagDto;
import de.ph87.home.tag.TagService;
import de.ph87.home.tunable.TunableService;
import jakarta.annotation.Nullable;
import lombok.NonNull;
@ -17,6 +19,8 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tuwien.auto.calimero.GroupAddress;
import java.util.Arrays;
@Slf4j
@Service
@Transactional
@ -34,32 +38,48 @@ public class DemoService {
private final AreaService areaService;
private final TagService tagService;
@EventListener(ApplicationStartedEvent.class)
public void startup() {
final TagDto tagLight = tagService.create("light", "Licht");
final TagDto tagDevice = tagService.create("device", "Gerät");
final TagDto tagDecoration = tagService.create("decoration", "Dekoration");
final TagDto tagDecorationInside = tagService.create("decoration_inside", "Dekoration Innen");
final TagDto tagDecorationWindow = tagService.create("decoration_window", "Dekoration Fenster");
final TagDto tagDecorationOutside = tagService.create("decoration_outside", "Dekoration Außen");
final TagDto tagAudio = tagService.create("media_audio", "Audio");
final TagDto tagVideo = tagService.create("media_video", "Video");
final TagDto tagMedia = tagService.create("media", "Media");
final TagDto tagShutter = tagService.create("shutter", "Rollladen");
final TagDto tagFront = tagService.create("house_front", "Haus Vorne");
final TagDto tagSide = tagService.create("house_side", "Haus Seite");
final TagDto tagHinten = tagService.create("house_backside", "Haus Hinten");
final AreaDto eg = area("eg", "EG");
final AreaDto wohnzimmer = area("wohnzimmer", "Wohnzimmer");
device(wohnzimmer, "fernseher", "Fernseher", 20, 4);
device(wohnzimmer, "verstaerker", "Verstärker", 825, 824);
tunable(wohnzimmer, "haengelampe", "Hängelampe", 1794, 1799, null, null, null, null);
tunable(wohnzimmer, "fensterdeko", "Fenster", 1823, 1822, null, null, null, null);
tunable(wohnzimmer, "spots", "", 28, 828, 2344, 2343, 1825, 1824);
shutter(wohnzimmer, "links", "Links", 1048);
shutter(wohnzimmer, "rechts", "Rechts", 1811);
device(wohnzimmer, "fernseher", "Fernseher", 20, 4, tagDevice, tagMedia, tagVideo);
device(wohnzimmer, "verstaerker", "Verstärker", 825, 824, tagDevice, tagMedia, tagAudio);
device(wohnzimmer, "haengelampe", "Hängelampe", 1794, 1799, tagLight);
device(wohnzimmer, "fensterdeko", "Fenster", 1823, 1822, tagDecoration, tagDecorationWindow);
tunable(wohnzimmer, "spots", "", 28, 828, 2344, 2343, 1825, 1824, tagLight);
shutter(wohnzimmer, "links", "Links", 1048, tagShutter, tagFront);
shutter(wohnzimmer, "rechts", "Rechts", 1811, tagShutter, tagFront);
final AreaDto kueche = area("kueche", "Küche");
tunable(kueche, "kueche_spots", "", 2311, 2304, 2342, 2341, 2321, 2317);
shutter(kueche, "kueche_seite", "Seite", 2316);
shutter(kueche, "kueche_theke", "Theke", 2320);
shutter(kueche, "kueche_tuer", "Tür", 2324);
tunable(kueche, "kueche_spots", "", 2311, 2304, 2342, 2341, 2321, 2317, tagLight);
shutter(kueche, "kueche_seite", "Seite", 2316, tagShutter, tagSide);
shutter(kueche, "kueche_theke", "Theke", 2320, tagShutter, tagHinten);
shutter(kueche, "kueche_tuer", "Tür", 2324, tagShutter, tagHinten);
tunable(eg, "eg_ambiente", "Ambiente", 849, 848, null, null, null, null);
device(eg, "eg_ambiente", "Ambiente", 849, 848, tagLight);
final AreaDto arbeitszimmer = area("arbeitszimmer", "Arbeitszimmer");
tunable(arbeitszimmer, "spots", "", 2058, 2057, 2067, 2069, 2049, 2054);
tunable(arbeitszimmer, "spots", "", 2058, 2057, 2067, 2069, 2049, 2054, tagLight);
final AreaDto keller = area("keller", "Keller");
device(keller, "receiver", "Receiver", 2561, 2560);
device(keller, "receiver", "Receiver", 2561, 2560, tagDevice);
}
@NonNull
@ -72,23 +92,26 @@ public class DemoService {
@NonNull final String subSlug,
@NonNull final String name,
@Nullable final Integer stateRead,
@Nullable final Integer stateWrite) {
@Nullable final Integer stateWrite,
@NonNull final TagDto... tagList
) {
final String slug = area.getSlug() + "_" + subSlug;
final String statePropertyId = slug + "_state";
knxPropertyService.create(statePropertyId, KnxPropertyType.BOOLEAN, adr(stateRead), adr(stateWrite));
deviceService.create(area.getUuid(), name, slug, statePropertyId);
deviceService.create(area.getUuid(), name, slug, statePropertyId, Arrays.stream(tagList).map(TagDto::getUuid).toList());
}
private void shutter(
@NonNull final AreaDto area,
@NonNull final String subSlug,
@NonNull final String name,
@Nullable final Integer positionReadWrite
@Nullable final Integer positionReadWrite,
@NonNull final TagDto... tagList
) {
final String slug = area.getSlug() + "_" + subSlug;
final String statePropertyId = slug + "_state";
knxPropertyService.create(statePropertyId, KnxPropertyType.DOUBLE, adr(positionReadWrite), adr(positionReadWrite));
shutterService.create(area.getUuid(), name, slug, statePropertyId);
shutterService.create(area.getUuid(), name, slug, statePropertyId, Arrays.stream(tagList).map(TagDto::getUuid).toList());
}
private void tunable(
@ -100,13 +123,14 @@ public class DemoService {
@Nullable final Integer brightnessRead,
@Nullable final Integer brightnessWrite,
@Nullable final Integer coldnessRead,
@Nullable final Integer coldnessWrite
@Nullable final Integer coldnessWrite,
@NonNull final TagDto... tagList
) {
final String slug = area.getSlug() + "_" + subSlug;
final String stateProperty = knxProperty(slug + "_state", KnxPropertyType.BOOLEAN, stateRead, stateWrite);
final String brightnessProperty = knxProperty(slug + "_brightness", KnxPropertyType.DOUBLE, brightnessRead, brightnessWrite);
final String coldnessProperty = knxProperty(slug + "_coldness", KnxPropertyType.DOUBLE, coldnessRead, coldnessWrite);
tunableService.create(area.getUuid(), name, slug, stateProperty, brightnessProperty, coldnessProperty);
tunableService.create(area.getUuid(), name, slug, stateProperty, brightnessProperty, coldnessProperty, Arrays.stream(tagList).map(TagDto::getUuid).toList());
}
@NonNull

View File

@ -1,19 +1,22 @@
package de.ph87.home.device;
import de.ph87.home.area.Area;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import de.ph87.home.tag.taggable.ITaggable;
import de.ph87.home.tag.Tag;
import jakarta.persistence.*;
import lombok.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static de.ph87.home.common.ListHelpers.merge;
@Entity
@Getter
@ToString
@NoArgsConstructor
public class Device {
public class Device implements ITaggable {
@Id
@NonNull
@ -36,11 +39,22 @@ public class Device {
@Column(nullable = false)
private String statePropertyId;
public Device(final Area area, @NonNull final String name, @NonNull final String slug, @NonNull final String statePropertyId) {
@Getter
@NonNull
@ManyToMany
private List<Tag> tagList = new ArrayList<>();
@Override
public List<?> getSearchableValues() {
return merge(List.of(area.getSlug(), area.getName(), slug, name), tagList.stream().map(Tag::getSlug).toList(), tagList.stream().map(Tag::getName).toList());
}
public Device(final Area area, @NonNull final String name, @NonNull final String slug, @NonNull final String statePropertyId, @NonNull final List<Tag> tagList) {
this.area = area;
this.name = name;
this.slug = slug;
this.statePropertyId = statePropertyId;
this.tagList = new ArrayList<>(tagList);
}
}

View File

@ -3,6 +3,7 @@ package de.ph87.home.device;
import de.ph87.home.area.AreaDto;
import de.ph87.home.property.PropertyDto;
import de.ph87.home.property.PropertyTypeMismatch;
import de.ph87.home.tag.Tag;
import de.ph87.home.web.IWebSocketMessage;
import jakarta.annotation.Nullable;
import lombok.Getter;
@ -37,6 +38,9 @@ public class DeviceDto implements IWebSocketMessage {
@ToString.Exclude
private final PropertyDto<Boolean> stateProperty;
@NonNull
private final List<String> tagList;
public DeviceDto(@NonNull final Device device, @Nullable final PropertyDto<Boolean> stateProperty) {
this.area = new AreaDto(device.getArea());
this.uuid = device.getUuid();
@ -44,6 +48,7 @@ public class DeviceDto implements IWebSocketMessage {
this.slug = device.getSlug();
this.statePropertyId = device.getStatePropertyId();
this.stateProperty = stateProperty;
this.tagList = device.getTagList().stream().map(Tag::getName).toList();
}
@Nullable

View File

@ -7,6 +7,8 @@ import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import static de.ph87.home.common.crud.SearchHelper.search;
@Getter
@ToString
public class DeviceFilter extends AbstractSearchFilter {
@ -31,7 +33,7 @@ public class DeviceFilter extends AbstractSearchFilter {
if (!stateFalse && Boolean.FALSE.equals(value)) {
return false;
}
return search(dto.getName());
return search(search, dto.getName());
}
}

View File

@ -5,6 +5,12 @@ import de.ph87.home.area.AreaService;
import de.ph87.home.common.crud.CrudAction;
import de.ph87.home.common.crud.EntityNotFound;
import de.ph87.home.property.*;
import de.ph87.home.search.SearchableDto;
import de.ph87.home.tag.Tag;
import de.ph87.home.tag.TagReader;
import de.ph87.home.tag.TaggableDto;
import de.ph87.home.tag.taggable.ITaggableService;
import de.ph87.home.tag.taggable.TaggableFilter;
import jakarta.annotation.Nullable;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
@ -14,14 +20,15 @@ import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import static de.ph87.home.common.crud.SearchHelper.search;
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class DeviceService {
public class DeviceService implements ITaggableService<DeviceDto> {
private final PropertyService propertyService;
@ -31,10 +38,13 @@ public class DeviceService {
private final AreaService areaService;
private final TagReader tagReader;
@NonNull
public DeviceDto create(@NonNull final String areaUuid, @NonNull final String name, @NonNull final String slug, @NonNull final String stateProperty) {
public DeviceDto create(@NonNull final String areaUuid, @NonNull final String name, @NonNull final String slug, @NonNull final String stateProperty, @NonNull final List<String> tagUuidList) {
final Area area = areaService.getByUuid(areaUuid);
return publish(deviceRepository.save(new Device(area, name, slug, stateProperty)), CrudAction.UPDATED);
final List<Tag> tagList = tagUuidList.stream().map(tagReader::getByUuid).toList();
return publish(deviceRepository.save(new Device(area, name, slug, stateProperty, tagList)), CrudAction.UPDATED);
}
public void setState(@NonNull final String uuidOrSlug, final boolean state) throws PropertyNotFound, PropertyNotWritable, PropertyTypeMismatch {
@ -66,17 +76,13 @@ public class DeviceService {
@NonNull
public List<DeviceDto> list(@Nullable final DeviceFilter filter) throws PropertyTypeMismatch {
final List<DeviceDto> all = deviceRepository.findAll().stream().map(this::toDto).toList();
if (filter == null) {
return all;
return deviceRepository.findAll().stream().map(this::toDto).toList();
}
final List<DeviceDto> results = new ArrayList<>();
for (final DeviceDto dto : all) {
if (filter.filter(dto)) {
results.add(dto);
}
}
return results;
return deviceRepository.findAll().stream()
.filter(device -> search(filter.getSearch(), device.getSearchableValues()))
.map(this::toDto)
.toList();
}
@EventListener(PropertyDto.class)
@ -97,4 +103,23 @@ public class DeviceService {
return toDto(getByUuid(uuid));
}
@Override
public List<TaggableDto<DeviceDto>> findTaggables(final @NonNull TaggableFilter filter) {
return deviceRepository.findAll().stream()
.filter(device -> search(filter.getSearch(), device.getSearchableValues()))
.filter(device -> device.tagListAnyMatch(filter.getTag()))
.map(this::toDto)
.map(TaggableDto::new)
.toList();
}
@Override
public List<SearchableDto<DeviceDto>> findSearchables(@NonNull final String term) {
return deviceRepository.findAll().stream()
.filter(device -> device.search(term))
.map(this::toDto)
.map(SearchableDto::new)
.toList();
}
}

View File

@ -5,12 +5,14 @@ import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import static de.ph87.home.common.crud.SearchHelper.search;
@Getter
@ToString
public class GroupFilter extends AbstractSearchFilter {
public boolean filter(@NonNull final Group group) {
return search(group.getName(), group.getAddress().toString());
return search(search, group.getName(), group.getAddress().toString());
}
}

View File

@ -0,0 +1,16 @@
package de.ph87.home.search;
import de.ph87.home.common.crud.SearchHelper;
import lombok.NonNull;
import java.util.List;
public interface ISearchable {
List<?> getSearchableValues();
default boolean search(@NonNull String term) {
return SearchHelper.search(term, getSearchableValues());
}
}

View File

@ -0,0 +1,11 @@
package de.ph87.home.search;
import lombok.NonNull;
import java.util.List;
public interface ISearchableService<T> {
List<SearchableDto<T>> findSearchables(@NonNull final String term);
}

View File

@ -0,0 +1,24 @@
package de.ph87.home.search;
import jakarta.annotation.Nullable;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("search")
public class SearchController {
private final SearchService searchService;
@PostMapping(consumes = "text/plain")
public List<? extends SearchableDto<?>> searchSearchables(@RequestBody(required = false) @Nullable final String term) {
return searchService.findSearchables(term == null ? "" : term);
}
}

View File

@ -0,0 +1,29 @@
package de.ph87.home.search;
import de.ph87.home.common.ListHelpers;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class SearchService {
private final List<ISearchableService<?>> searchableServices;
@NonNull
public List<? extends SearchableDto<?>> findSearchables(@NonNull final String term) {
return searchableServices.stream()
.map(iSearchableService -> iSearchableService.findSearchables(term))
.reduce(ListHelpers::merge)
.orElse(new ArrayList<>());
}
}

View File

@ -0,0 +1,17 @@
package de.ph87.home.search;
import lombok.Data;
@Data
public class SearchableDto<T> {
private final String _type_;
private final T payload;
public SearchableDto(final T payload) {
this.payload = payload;
this._type_ = payload.getClass().getSimpleName();
}
}

View File

@ -1,19 +1,22 @@
package de.ph87.home.shutter;
import de.ph87.home.area.Area;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import de.ph87.home.tag.taggable.ITaggable;
import de.ph87.home.tag.Tag;
import jakarta.persistence.*;
import lombok.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static de.ph87.home.common.ListHelpers.merge;
@Entity
@Getter
@ToString
@NoArgsConstructor
public class Shutter {
public class Shutter implements ITaggable {
@Id
@NonNull
@ -36,11 +39,22 @@ public class Shutter {
@Column(nullable = false)
private String positionPropertyId;
public Shutter(@NonNull final Area area, @NonNull final String name, @NonNull final String slug, @NonNull final String positionPropertyId) {
@Getter
@NonNull
@ManyToMany
private List<Tag> tagList = new ArrayList<>();
@Override
public List<?> getSearchableValues() {
return merge(List.of(area.getSlug(), area.getName(), slug, name), tagList.stream().map(Tag::getSlug).toList(), tagList.stream().map(Tag::getName).toList());
}
public Shutter(@NonNull final Area area, @NonNull final String name, @NonNull final String slug, @NonNull final String positionPropertyId, final List<Tag> tagList) {
this.area = area;
this.name = name;
this.slug = slug;
this.positionPropertyId = positionPropertyId;
this.tagList = new ArrayList<>(tagList);
}
}

View File

@ -3,6 +3,7 @@ package de.ph87.home.shutter;
import de.ph87.home.area.AreaDto;
import de.ph87.home.property.PropertyDto;
import de.ph87.home.property.PropertyTypeMismatch;
import de.ph87.home.tag.Tag;
import de.ph87.home.web.IWebSocketMessage;
import jakarta.annotation.Nullable;
import lombok.Getter;
@ -20,6 +21,7 @@ public class ShutterDto implements IWebSocketMessage {
@NonNull
private final AreaDto area;
@NonNull
private final String uuid;
@ -36,6 +38,9 @@ public class ShutterDto implements IWebSocketMessage {
@ToString.Exclude
private final PropertyDto<Double> positionProperty;
@NonNull
private final List<String> tagList;
public ShutterDto(@NonNull final Shutter shutter, @Nullable final PropertyDto<Double> positionProperty) {
this.area = new AreaDto(shutter.getArea());
this.uuid = shutter.getUuid();
@ -43,6 +48,7 @@ public class ShutterDto implements IWebSocketMessage {
this.slug = shutter.getSlug();
this.positionPropertyId = shutter.getPositionPropertyId();
this.positionProperty = positionProperty;
this.tagList = shutter.getTagList().stream().map(Tag::getName).toList();
}
@Nullable

View File

@ -9,6 +9,8 @@ import lombok.ToString;
import java.util.Objects;
import static de.ph87.home.common.crud.SearchHelper.search;
@Getter
@ToString
public class ShutterFilter extends AbstractSearchFilter {
@ -39,7 +41,7 @@ public class ShutterFilter extends AbstractSearchFilter {
if (!positionClosed && Objects.equals(value, 100.0)) {
return false;
}
return search(dto.getName());
return search(search, dto.getName());
}
}

View File

@ -5,6 +5,12 @@ import de.ph87.home.area.AreaService;
import de.ph87.home.common.crud.CrudAction;
import de.ph87.home.common.crud.EntityNotFound;
import de.ph87.home.property.*;
import de.ph87.home.search.SearchableDto;
import de.ph87.home.tag.Tag;
import de.ph87.home.tag.TagReader;
import de.ph87.home.tag.TaggableDto;
import de.ph87.home.tag.taggable.ITaggableService;
import de.ph87.home.tag.taggable.TaggableFilter;
import jakarta.annotation.Nullable;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
@ -14,14 +20,15 @@ import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import static de.ph87.home.common.crud.SearchHelper.search;
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class ShutterService {
public class ShutterService implements ITaggableService<ShutterDto> {
private final AreaService areaService;
@ -31,10 +38,19 @@ public class ShutterService {
private final ApplicationEventPublisher applicationEventPublisher;
private final TagReader tagReader;
@NonNull
public ShutterDto create(@NonNull final String areaUuid, @NonNull final String name, @NonNull final String slug, @NonNull final String positionProperty) {
public ShutterDto create(
@NonNull final String areaUuid,
@NonNull final String name,
@NonNull final String slug,
@NonNull final String positionProperty,
@NonNull final List<String> tagUuidList
) {
final Area area = areaService.getByUuid(areaUuid);
return publish(shutterRepository.save(new Shutter(area, name, slug, positionProperty)), CrudAction.CREATED);
final List<Tag> tagList = tagUuidList.stream().map(tagReader::getByUuid).toList();
return publish(shutterRepository.save(new Shutter(area, name, slug, positionProperty, tagList)), CrudAction.CREATED);
}
public void setPosition(@NonNull final String uuidOrSlug, final double position) throws PropertyNotFound, PropertyNotWritable, PropertyTypeMismatch {
@ -66,17 +82,13 @@ public class ShutterService {
@NonNull
public List<ShutterDto> list(@Nullable final ShutterFilter filter) throws PropertyTypeMismatch {
final List<ShutterDto> all = shutterRepository.findAll().stream().map(this::toDto).toList();
if (filter == null) {
return all;
return shutterRepository.findAll().stream().map(this::toDto).toList();
}
final List<ShutterDto> results = new ArrayList<>();
for (final ShutterDto dto : all) {
if (filter.filter(dto)) {
results.add(dto);
}
}
return results;
return shutterRepository.findAll().stream()
.filter(shutter -> search(filter.getSearch(), shutter.getSearchableValues()))
.map(this::toDto)
.toList();
}
@EventListener(PropertyDto.class)
@ -97,4 +109,23 @@ public class ShutterService {
return toDto(getByUuid(uuid));
}
@Override
public List<TaggableDto<ShutterDto>> findTaggables(final @NonNull TaggableFilter filter) {
return shutterRepository.findAll().stream()
.filter(shutter -> shutter.tagListAnyMatch(filter.getTag()))
.filter(device -> search(filter.getSearch(), device.getSearchableValues()))
.map(this::toDto)
.map(TaggableDto::new)
.toList();
}
@Override
public List<SearchableDto<ShutterDto>> findSearchables(@NonNull final String term) {
return shutterRepository.findAll().stream()
.filter(shutter -> shutter.search(term))
.map(this::toDto)
.map(SearchableDto::new)
.toList();
}
}

View File

@ -0,0 +1,36 @@
package de.ph87.home.tag;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.ToString;
import java.util.UUID;
@Entity
@Getter
@ToString
@NoArgsConstructor
public class Tag {
@Id
@NonNull
private String uuid = UUID.randomUUID().toString();
@NonNull
@Column(nullable = false, unique = true)
private String slug;
@NonNull
@Column(nullable = false, unique = true)
private String name;
public Tag(@NonNull final String slug, @NonNull final String name) {
this.slug = slug;
this.name = name;
}
}

View File

@ -0,0 +1,12 @@
package de.ph87.home.tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@RequestMapping("Tag")
public class TagController {
}

View File

@ -0,0 +1,20 @@
package de.ph87.home.tag;
import lombok.Data;
import lombok.NonNull;
@Data
public class TagDto {
@NonNull
private final String uuid;
@NonNull
private final String name;
public TagDto(@NonNull final Tag tag) {
this.uuid = tag.getUuid();
this.name = tag.getName();
}
}

View File

@ -0,0 +1,22 @@
package de.ph87.home.tag;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class TagReader {
private final TagRepository tagRepository;
@NonNull
public Tag getByUuid(@NonNull final String uuid) {
return tagRepository.findById(uuid).orElseThrow();
}
}

View File

@ -0,0 +1,7 @@
package de.ph87.home.tag;
import org.springframework.data.repository.ListCrudRepository;
public interface TagRepository extends ListCrudRepository<Tag, String> {
}

View File

@ -0,0 +1,22 @@
package de.ph87.home.tag;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class TagService {
private final TagRepository tagRepository;
@NonNull
public TagDto create(final @NonNull String slug, @NonNull final String name) {
return new TagDto(tagRepository.save(new Tag(slug, name)));
}
}

View File

@ -0,0 +1,17 @@
package de.ph87.home.tag;
import lombok.Data;
@Data
public class TaggableDto<T> {
private final String _type_;
private final T payload;
public TaggableDto(final T payload) {
this.payload = payload;
this._type_ = payload.getClass().getSimpleName();
}
}

View File

@ -0,0 +1,20 @@
package de.ph87.home.tag.taggable;
import de.ph87.home.search.ISearchable;
import de.ph87.home.tag.Tag;
import lombok.NonNull;
import java.util.List;
public interface ITaggable extends ISearchable {
List<Tag> getTagList();
default boolean tagListAnyMatch(@NonNull final String term) {
if (term.isEmpty()) {
return true;
}
return getTagList().stream().anyMatch(tag -> tag.getSlug().equalsIgnoreCase(term));
}
}

View File

@ -0,0 +1,13 @@
package de.ph87.home.tag.taggable;
import de.ph87.home.search.ISearchableService;
import de.ph87.home.tag.TaggableDto;
import lombok.NonNull;
import java.util.List;
public interface ITaggableService<T> extends ISearchableService<T> {
List<TaggableDto<T>> findTaggables(final @NonNull TaggableFilter filter);
}

View File

@ -0,0 +1,26 @@
package de.ph87.home.tag.taggable;
import de.ph87.home.tag.TaggableDto;
import jakarta.annotation.Nullable;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("Taggable")
public class TaggableController {
private final TaggableService taggableService;
@PostMapping(value = "list")
public List<? extends TaggableDto<?>> list(@RequestBody @NonNull final TaggableFilter filter) {
return taggableService.list(filter);
}
}

View File

@ -0,0 +1,15 @@
package de.ph87.home.tag.taggable;
import lombok.Data;
import lombok.NonNull;
@Data
public class TaggableFilter {
@NonNull
private final String tag;
@NonNull
private final String search;
}

View File

@ -0,0 +1,30 @@
package de.ph87.home.tag.taggable;
import de.ph87.home.common.ListHelpers;
import de.ph87.home.tag.TaggableDto;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class TaggableService {
private final List<ITaggableService<?>> taggableServices;
@NonNull
public List<? extends TaggableDto<?>> list(@NonNull final TaggableFilter filter) {
return taggableServices.stream()
.map(iTaggableService -> iTaggableService.findTaggables(filter))
.reduce(ListHelpers::merge)
.orElse(new ArrayList<>());
}
}

View File

@ -1,19 +1,22 @@
package de.ph87.home.tunable;
import de.ph87.home.area.Area;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import de.ph87.home.tag.taggable.ITaggable;
import de.ph87.home.tag.Tag;
import jakarta.persistence.*;
import lombok.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static de.ph87.home.common.ListHelpers.merge;
@Entity
@Getter
@ToString
@NoArgsConstructor
public class Tunable {
public class Tunable implements ITaggable {
@Id
@NonNull
@ -46,13 +49,24 @@ public class Tunable {
@Column(nullable = false)
private String coldnessPropertyId;
public Tunable(@NonNull final Area area, @NonNull final String name, @NonNull final String slug, @NonNull final String statePropertyId, @NonNull final String brightnessPropertyId, @NonNull final String coldnessPropertyId) {
@Getter
@NonNull
@ManyToMany
private List<Tag> tagList = new ArrayList<>();
@Override
public List<?> getSearchableValues() {
return merge(List.of(area.getSlug(), area.getName(), slug, name), tagList.stream().map(Tag::getSlug).toList(), tagList.stream().map(Tag::getName).toList());
}
public Tunable(@NonNull final Area area, @NonNull final String name, @NonNull final String slug, @NonNull final String statePropertyId, @NonNull final String brightnessPropertyId, @NonNull final String coldnessPropertyId, final List<Tag> tagList) {
this.area = area;
this.name = name;
this.slug = slug;
this.statePropertyId = statePropertyId;
this.brightnessPropertyId = brightnessPropertyId;
this.coldnessPropertyId = coldnessPropertyId;
this.tagList = new ArrayList<>(tagList);
}
}

View File

@ -3,6 +3,7 @@ package de.ph87.home.tunable;
import de.ph87.home.area.AreaDto;
import de.ph87.home.property.PropertyDto;
import de.ph87.home.property.PropertyTypeMismatch;
import de.ph87.home.tag.Tag;
import de.ph87.home.web.IWebSocketMessage;
import jakarta.annotation.Nullable;
import lombok.Getter;
@ -51,6 +52,9 @@ public class TunableDto implements IWebSocketMessage {
@ToString.Exclude
private final PropertyDto<Double> coldnessProperty;
@NonNull
private final List<String> tagList;
public TunableDto(@NonNull final Tunable tunable, @Nullable final PropertyDto<Boolean> stateProperty, @Nullable final PropertyDto<Double> brightnessProperty, @Nullable final PropertyDto<Double> coldnessProperty) {
this.area = new AreaDto(tunable.getArea());
this.uuid = tunable.getUuid();
@ -62,6 +66,7 @@ public class TunableDto implements IWebSocketMessage {
this.stateProperty = stateProperty;
this.brightnessProperty = brightnessProperty;
this.coldnessProperty = coldnessProperty;
this.tagList = tunable.getTagList().stream().map(Tag::getName).toList();
}
@Nullable

View File

@ -7,6 +7,8 @@ import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import static de.ph87.home.common.crud.SearchHelper.search;
@Getter
@ToString
public class TunableFilter extends AbstractSearchFilter {
@ -31,7 +33,7 @@ public class TunableFilter extends AbstractSearchFilter {
if (!stateFalse && Boolean.FALSE.equals(value)) {
return false;
}
return search(dto.getName());
return search(search, dto.getName());
}
}

View File

@ -5,6 +5,12 @@ import de.ph87.home.area.AreaService;
import de.ph87.home.common.crud.CrudAction;
import de.ph87.home.common.crud.EntityNotFound;
import de.ph87.home.property.*;
import de.ph87.home.search.SearchableDto;
import de.ph87.home.tag.Tag;
import de.ph87.home.tag.TagReader;
import de.ph87.home.tag.TaggableDto;
import de.ph87.home.tag.taggable.ITaggableService;
import de.ph87.home.tag.taggable.TaggableFilter;
import jakarta.annotation.Nullable;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
@ -14,14 +20,15 @@ import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import static de.ph87.home.common.crud.SearchHelper.search;
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class TunableService {
public class TunableService implements ITaggableService<TunableDto> {
private final PropertyService propertyService;
@ -31,10 +38,21 @@ public class TunableService {
private final AreaService areaService;
private final TagReader tagReader;
@NonNull
public TunableDto create(@NonNull final String areaUuid, @NonNull final String name, @NonNull final String slug, @NonNull final String stateProperty, @NonNull final String brightnessProperty, @NonNull final String coldnessProperty) {
public TunableDto create(
@NonNull final String areaUuid,
@NonNull final String name,
@NonNull final String slug,
@NonNull final String stateProperty,
@NonNull final String brightnessProperty,
@NonNull final String coldnessProperty,
@NonNull final List<String> tagUuidList
) {
final Area area = areaService.getByUuid(areaUuid);
return publish(tunableRepository.save(new Tunable(area, name, slug, stateProperty, brightnessProperty, coldnessProperty)), CrudAction.UPDATED);
final List<Tag> tagList = tagUuidList.stream().map(tagReader::getByUuid).toList();
return publish(tunableRepository.save(new Tunable(area, name, slug, stateProperty, brightnessProperty, coldnessProperty, tagList)), CrudAction.UPDATED);
}
@NonNull
@ -86,17 +104,13 @@ public class TunableService {
@NonNull
public List<TunableDto> list(@Nullable final TunableFilter filter) throws PropertyTypeMismatch {
final List<TunableDto> all = tunableRepository.findAll().stream().map(this::toDto).toList();
if (filter == null) {
return all;
return tunableRepository.findAll().stream().map(this::toDto).toList();
}
final List<TunableDto> results = new ArrayList<>();
for (final TunableDto dto : all) {
if (filter.filter(dto)) {
results.add(dto);
}
}
return results;
return tunableRepository.findAll().stream()
.filter(tunable -> search(filter.getSearch(), tunable.getSearchableValues()))
.map(this::toDto)
.toList();
}
@EventListener(PropertyDto.class)
@ -117,4 +131,23 @@ public class TunableService {
return toDto(getByUuid(uuid));
}
@Override
public List<TaggableDto<TunableDto>> findTaggables(final @NonNull TaggableFilter filter) {
return tunableRepository.findAll().stream()
.filter(tunable -> tunable.tagListAnyMatch(filter.getTag()))
.filter(device -> search(filter.getSearch(), device.getSearchableValues()))
.map(this::toDto)
.map(TaggableDto::new)
.toList();
}
@Override
public List<SearchableDto<TunableDto>> findSearchables(@NonNull final String term) {
return tunableRepository.findAll().stream()
.filter(tunable -> tunable.search(term))
.map(this::toDto)
.map(SearchableDto::new)
.toList();
}
}