diff --git a/src/main/angular/src/app/pages/tunable-list-page/tunable-list-page.component.html b/src/main/angular/src/app/pages/tunable-list-page/tunable-list-page.component.html
new file mode 100644
index 0000000..e256d68
--- /dev/null
+++ b/src/main/angular/src/app/pages/tunable-list-page/tunable-list-page.component.html
@@ -0,0 +1,8 @@
+
diff --git a/src/main/angular/src/app/pages/tunable-list-page/tunable-list-page.component.less b/src/main/angular/src/app/pages/tunable-list-page/tunable-list-page.component.less
new file mode 100644
index 0000000..e4e2c79
--- /dev/null
+++ b/src/main/angular/src/app/pages/tunable-list-page/tunable-list-page.component.less
@@ -0,0 +1,5 @@
+@import "../../../config";
+
+input {
+ border-bottom: @border solid lightgray;
+}
diff --git a/src/main/angular/src/app/pages/tunable-list-page/tunable-list-page.component.ts b/src/main/angular/src/app/pages/tunable-list-page/tunable-list-page.component.ts
new file mode 100644
index 0000000..c7dca62
--- /dev/null
+++ b/src/main/angular/src/app/pages/tunable-list-page/tunable-list-page.component.ts
@@ -0,0 +1,68 @@
+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.subs.push(this.tunableService.subscribe(tunable => this.updateTunable(tunable)));
+ 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.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();
+ }
+ }
+
+}
diff --git a/src/main/angular/src/app/shared/tunable-list/tunable-list.component.html b/src/main/angular/src/app/shared/tunable-list/tunable-list.component.html
new file mode 100644
index 0000000..996d333
--- /dev/null
+++ b/src/main/angular/src/app/shared/tunable-list/tunable-list.component.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+ {{ tunable.name }}
+
+
+
+ {{ tunable.stateProperty?.lastValueChange | relative:now }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/angular/src/app/shared/tunable-list/tunable-list.component.less b/src/main/angular/src/app/shared/tunable-list/tunable-list.component.less
new file mode 100644
index 0000000..6a1e25b
--- /dev/null
+++ b/src/main/angular/src/app/shared/tunable-list/tunable-list.component.less
@@ -0,0 +1,65 @@
+@import "../../../config";
+
+.tunableList {
+ overflow-y: auto;
+ height: 100%;
+
+ .tunable {
+
+ .name {
+ float: left;
+ }
+
+ .timestamp {
+ float: right;
+ font-size: 80%;
+ }
+
+ .sliders {
+ float: left;
+ clear: left;
+ width: 60%;
+ overflow: visible;
+ padding-top: 0.4em;
+
+ .slider {
+ float: left;
+ clear: left;
+ margin-left: @space;
+ width: 100%;
+ overflow: visible;
+
+ input {
+ width: 100%;
+ height: 2em;
+ }
+
+ }
+
+ }
+
+ .actions {
+ float: right;
+
+ .switch {
+ 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");
+ }
+
+ }
+
+ }
+
+}
diff --git a/src/main/angular/src/app/shared/tunable-list/tunable-list.component.ts b/src/main/angular/src/app/shared/tunable-list/tunable-list.component.ts
new file mode 100644
index 0000000..07b685f
--- /dev/null
+++ b/src/main/angular/src/app/shared/tunable-list/tunable-list.component.ts
@@ -0,0 +1,54 @@
+import {Component, Input, OnDestroy, OnInit} from '@angular/core';
+import {NgClass, NgForOf} from '@angular/common';
+import {Tunable} from '../../api/Tunable/Tunable';
+import {TunableService} from '../../api/Tunable/tunable.service';
+import {RelativePipe} from '../../api/common/relative.pipe';
+import {Subscription, timer} from 'rxjs';
+import {FormsModule} from '@angular/forms';
+
+@Component({
+ selector: 'app-tunable-list',
+ standalone: true,
+ imports: [
+ NgForOf,
+ NgClass,
+ RelativePipe,
+ FormsModule
+ ],
+ templateUrl: './tunable-list.component.html',
+ styleUrl: './tunable-list.component.less'
+})
+export class TunableListComponent implements OnInit, OnDestroy {
+
+ @Input()
+ tunableList: Tunable[] = [];
+
+ protected readonly Tunable = Tunable;
+
+ protected now: Date = new Date();
+
+ private readonly subs: Subscription[] = [];
+
+ constructor(
+ protected readonly tunableService: TunableService,
+ ) {
+ //
+ }
+
+ 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(tunable: Tunable) {
+ return {
+ "stateOn": tunable.stateProperty?.state?.value === true,
+ "stateOff": tunable.stateProperty?.state?.value === false,
+ };
+ }
+
+}
diff --git a/src/main/angular/src/styles.less b/src/main/angular/src/styles.less
index c6aada8..f9c2c35 100644
--- a/src/main/angular/src/styles.less
+++ b/src/main/angular/src/styles.less
@@ -17,7 +17,7 @@ div {
overflow: hidden;
}
-input {
+input[type=text] {
all: unset;
width: calc(100% - 2 * 0.2em - @border);
padding-left: 0.2em;
diff --git a/src/main/java/de/ph87/home/common/map/MapHelper.java b/src/main/java/de/ph87/home/common/map/MapHelper.java
new file mode 100644
index 0000000..7117f60
--- /dev/null
+++ b/src/main/java/de/ph87/home/common/map/MapHelper.java
@@ -0,0 +1,69 @@
+package de.ph87.home.common.map;
+
+import jakarta.annotation.Nullable;
+import lombok.NonNull;
+
+import java.util.function.BiConsumer;
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+public class MapHelper {
+
+ public static
void apply(@Nullable final T t, @NonNull final Consumer<@NonNull T> apply) {
+ if (t == null) {
+ return;
+ }
+ apply.accept(t);
+ }
+
+ public static void apply(@Nullable final T t, @NonNull final U u, @NonNull final BiConsumer<@NonNull T, @NonNull U> apply) {
+ if (t == null) {
+ return;
+ }
+ apply.accept(t, u);
+ }
+
+ @Nullable
+ public static R map(@Nullable final T t, @NonNull final Function<@NonNull T, R> map) {
+ return map(t, map, null);
+ }
+
+ @Nullable
+ public static R map(@Nullable final T t, @NonNull final Function<@NonNull T, @NonNull R> map, @Nullable final R fallback) {
+ if (t == null) {
+ return fallback;
+ }
+ return map.apply(t);
+ }
+
+ @NonNull
+ public static R mapNN(@Nullable final T t, @NonNull final Function<@NonNull T, @NonNull R> map, @NonNull final R fallback) {
+ if (t == null) {
+ return fallback;
+ }
+ return map.apply(t);
+ }
+
+ @Nullable
+ public static R biMap(@Nullable final T t, @NonNull final U u, @NonNull final BiFunction<@NonNull T, @NonNull U, R> map) {
+ return biMap(t, u, map, null);
+ }
+
+ @Nullable
+ public static R biMap(@Nullable final T t, @NonNull final U u, @NonNull final BiFunction<@NonNull T, @NonNull U, @NonNull R> map, @Nullable final R fallback) {
+ if (t == null) {
+ return fallback;
+ }
+ return map.apply(t, u);
+ }
+
+ @NonNull
+ public static R biMapNN(@Nullable final T t, @NonNull final U u, @NonNull final BiFunction<@NonNull T, @NonNull U, @NonNull R> map, @NonNull final R fallback) {
+ if (t == null) {
+ return fallback;
+ }
+ return map.apply(t, u);
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/demo/DemoService.java b/src/main/java/de/ph87/home/demo/DemoService.java
index 92fd85f..1254d76 100644
--- a/src/main/java/de/ph87/home/demo/DemoService.java
+++ b/src/main/java/de/ph87/home/demo/DemoService.java
@@ -4,6 +4,9 @@ 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.tunable.TunableService;
+import jakarta.annotation.Nullable;
+import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationStartedEvent;
@@ -24,43 +27,73 @@ public class DemoService {
private final ShutterService shutterService;
+ private final TunableService tunableService;
+
@EventListener(ApplicationStartedEvent.class)
public void startup() {
- knxPropertyService.create("eg_ambiente", KnxPropertyType.BOOLEAN, adr(849), adr(848));
- deviceService.create("EG Ambiente", "eg_ambiente", "eg_ambiente");
+ device("eg_ambiente", "EG Ambiente", 849, 848);
+ device("fernseher", "Wohnzimmer Fernseher", 20, 4);
+ device("verstaerker", "Wohnzimmer Verstärker", 825, 824);
+ device("fensterdeko", "Wohnzimmer Fenster", 1823, 1822);
+ device("haengelampe", "Wohnzimmer Hängelampe", 1794, 1799);
+ device("receiver", "Receiver", 2561, 2560);
- knxPropertyService.create("fernseher", KnxPropertyType.BOOLEAN, adr(20), adr(4));
- deviceService.create("Wohnzimmer Fernseher", "fernseher", "fernseher");
+ tunable("wohnzimmer_spots", "Wohnzimmer", 28, 828, 2344, 2343, 1825, 1824);
+ tunable("kueche_spots", "Küche", 2311, 2304, 2342, 2341, 2321, 2317);
+ tunable("arbeitszimmer_spots", "Arbeitszimmer", 2058, 2057, 2067, 2069, 2049, 2054);
- knxPropertyService.create("verstaerker", KnxPropertyType.BOOLEAN, adr(825), adr(824));
- deviceService.create("Wohnzimmer Verstärker", "verstaerker", "verstaerker");
-
- knxPropertyService.create("fensterdeko", KnxPropertyType.BOOLEAN, adr(1823), adr(1822));
- deviceService.create("Wohnzimmer Fenster", "fensterdeko", "fensterdeko");
-
- knxPropertyService.create("haengelampe", KnxPropertyType.BOOLEAN, adr(1794), adr(1799));
- deviceService.create("Wohnzimmer Hängelampe", "haengelampe", "haengelampe");
-
- knxPropertyService.create("receiver", KnxPropertyType.BOOLEAN, adr(2561), adr(2560));
- deviceService.create("Receiver", "receiver", "receiver");
-
- knxPropertyService.create("wohnzimmer_links", KnxPropertyType.DOUBLE, adr(1048), adr(1048));
- shutterService.create("Wohnzimmer Links", "wohnzimmer_links", "wohnzimmer_links");
-
- knxPropertyService.create("wohnzimmer_rechts", KnxPropertyType.DOUBLE, adr(1811), adr(1811));
- shutterService.create("Wohnzimmer Rechts", "wohnzimmer_rechts", "wohnzimmer_rechts");
-
- knxPropertyService.create("kueche_seite", KnxPropertyType.DOUBLE, adr(2316), adr(2316));
- shutterService.create("Küche Seite", "kueche_seite", "kueche_seite");
-
- knxPropertyService.create("kueche_theke", KnxPropertyType.DOUBLE, adr(2320), adr(2320));
- shutterService.create("Küche Theke", "kueche_theke", "kueche_theke");
-
- knxPropertyService.create("kueche_tuer", KnxPropertyType.DOUBLE, adr(2324), adr(2324));
- shutterService.create("Küche Tür", "kueche_tuer", "kueche_tuer");
+ shutter("wohnzimmer_links", "Wohnzimmer Links", 1048);
+ shutter("wohnzimmer_rechts", "Wohnzimmer Rechts", 1811);
+ shutter("kueche_seite", "Küche Seite", 2316);
+ shutter("kueche_theke", "Küche Theke", 2320);
+ shutter("kueche_tuer", "Küche Tür", 2324);
}
- private static GroupAddress adr(final int rawGroupAddress) {
+ private void device(
+ @NonNull final String slug,
+ @NonNull final String name,
+ @Nullable final Integer stateRead,
+ @Nullable final Integer stateWrite
+ ) {
+ knxPropertyService.create(slug, KnxPropertyType.BOOLEAN, adr(stateRead), adr(stateWrite));
+ deviceService.create(name, slug, slug);
+ }
+
+ private void shutter(
+ @NonNull final String slug,
+ @NonNull final String name,
+ @Nullable final Integer positionReadWrite
+ ) {
+ knxPropertyService.create(slug, KnxPropertyType.DOUBLE, adr(positionReadWrite), adr(positionReadWrite));
+ shutterService.create(name, slug, slug);
+ }
+
+ private void tunable(
+ @NonNull final String slug,
+ @NonNull final String name,
+ @Nullable final Integer stateRead,
+ @Nullable final Integer stateWrite,
+ @Nullable final Integer brightnessRead,
+ @Nullable final Integer brightnessWrite,
+ @Nullable final Integer coldnessRead,
+ @Nullable final Integer coldnessWrite
+ ) {
+ final String stateProperty = slug + "_state";
+ knxPropertyService.create(stateProperty, KnxPropertyType.BOOLEAN, adr(stateRead), adr(stateWrite));
+
+ final String brightnessProperty = slug + "_brightness";
+ knxPropertyService.create(brightnessProperty, KnxPropertyType.DOUBLE, adr(brightnessRead), adr(brightnessWrite));
+
+ final String coldnessProperty = slug + "_coldness";
+ knxPropertyService.create(coldnessProperty, KnxPropertyType.DOUBLE, adr(coldnessRead), adr(coldnessWrite));
+
+ tunableService.create(name, slug, stateProperty, brightnessProperty, coldnessProperty);
+ }
+
+ private static GroupAddress adr(final Integer rawGroupAddress) {
+ if (rawGroupAddress == null) {
+ return null;
+ }
return GroupAddress.freeStyle(rawGroupAddress);
}
diff --git a/src/main/java/de/ph87/home/tunable/Tunable.java b/src/main/java/de/ph87/home/tunable/Tunable.java
new file mode 100644
index 0000000..b781cb2
--- /dev/null
+++ b/src/main/java/de/ph87/home/tunable/Tunable.java
@@ -0,0 +1,51 @@
+package de.ph87.home.tunable;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import lombok.*;
+
+import java.util.UUID;
+
+@Entity
+@Getter
+@ToString
+@NoArgsConstructor
+public class Tunable {
+
+ @Id
+ @NonNull
+ private String uuid = UUID.randomUUID().toString();
+
+ @NonNull
+ @Column(nullable = false)
+ private String name;
+
+ @NonNull
+ @Column(nullable = false, unique = true)
+ private String slug;
+
+ @Setter
+ @NonNull
+ @Column(nullable = false)
+ private String statePropertyId;
+
+ @Setter
+ @NonNull
+ @Column(nullable = false)
+ private String brightnessPropertyId;
+
+ @Setter
+ @NonNull
+ @Column(nullable = false)
+ private String coldnessPropertyId;
+
+ public Tunable(@NonNull final String name, @NonNull final String slug, @NonNull final String statePropertyId, @NonNull final String brightnessPropertyId, @NonNull final String coldnessPropertyId) {
+ this.name = name;
+ this.slug = slug;
+ this.statePropertyId = statePropertyId;
+ this.brightnessPropertyId = brightnessPropertyId;
+ this.coldnessPropertyId = coldnessPropertyId;
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/tunable/TunableController.java b/src/main/java/de/ph87/home/tunable/TunableController.java
new file mode 100644
index 0000000..d6d0687
--- /dev/null
+++ b/src/main/java/de/ph87/home/tunable/TunableController.java
@@ -0,0 +1,85 @@
+package de.ph87.home.tunable;
+
+import de.ph87.home.property.PropertyNotFound;
+import de.ph87.home.property.PropertyNotWritable;
+import de.ph87.home.property.PropertyTypeMismatch;
+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.KNXFormatException;
+
+import java.util.List;
+
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("Tunable")
+public class TunableController {
+
+ private final TunableService tunableService;
+
+ @NonNull
+ @GetMapping("getByUuid/{id}")
+ @ExceptionHandler(KNXFormatException.class)
+ private TunableDto getByUuid(@PathVariable final String id, @NonNull final HttpServletRequest request) {
+ log.debug("getByUuid: path={}", request.getServletPath());
+ return tunableService.getByUuidDto(id);
+ }
+
+ @NonNull
+ @RequestMapping(value = "list", method = {RequestMethod.GET, RequestMethod.POST})
+ private List list(@RequestBody(required = false) @Nullable final TunableFilter filter, @NonNull final HttpServletRequest request) throws PropertyTypeMismatch {
+ log.debug("list: path={} filter={}", request.getServletPath(), filter);
+ return tunableService.list(filter);
+ }
+
+ @NonNull
+ @GetMapping("get/{uuidOrSlug}")
+ private TunableDto get(@PathVariable @NonNull final String uuidOrSlug, @NonNull final HttpServletRequest request) {
+ log.debug("get: path={}", request.getServletPath());
+ return tunableService.getByUuidOrSlugDto(uuidOrSlug);
+ }
+
+ @Nullable
+ @GetMapping("getState/{uuidOrSlug}")
+ private Boolean getState(@PathVariable @NonNull final String uuidOrSlug, @NonNull final HttpServletRequest request) throws PropertyTypeMismatch {
+ log.debug("getState: path={}", request.getServletPath());
+ return tunableService.getByUuidOrSlugDto(uuidOrSlug).getStateValue();
+ }
+
+ @GetMapping("setState/{uuidOrSlug}/{state}")
+ private TunableDto setState(@PathVariable @NonNull final String uuidOrSlug, @PathVariable final boolean state, @NonNull final HttpServletRequest request) throws PropertyNotFound, PropertyNotWritable, PropertyTypeMismatch {
+ log.debug("setState: path={}", request.getServletPath());
+ return tunableService.setState(uuidOrSlug, state);
+ }
+
+ @Nullable
+ @GetMapping("getBrightness/{uuidOrSlug}")
+ private Double getBrightness(@PathVariable @NonNull final String uuidOrSlug, @NonNull final HttpServletRequest request) throws PropertyTypeMismatch {
+ log.debug("getBrightness: path={}", request.getServletPath());
+ return tunableService.getByUuidOrSlugDto(uuidOrSlug).getBrightnessValue();
+ }
+
+ @GetMapping("setBrightness/{uuidOrSlug}/{brightness}")
+ private TunableDto setBrightness(@PathVariable @NonNull final String uuidOrSlug, @PathVariable final double brightness, @NonNull final HttpServletRequest request) throws PropertyNotFound, PropertyNotWritable, PropertyTypeMismatch {
+ log.debug("setBrightness: path={}", request.getServletPath());
+ return tunableService.setBrightness(uuidOrSlug, brightness);
+ }
+
+ @Nullable
+ @GetMapping("getColdness/{uuidOrSlug}")
+ private Double getColdness(@PathVariable @NonNull final String uuidOrSlug, @NonNull final HttpServletRequest request) throws PropertyTypeMismatch {
+ log.debug("getColdness: path={}", request.getServletPath());
+ return tunableService.getByUuidOrSlugDto(uuidOrSlug).getColdnessValue();
+ }
+
+ @GetMapping("setColdness/{uuidOrSlug}/{coldness}")
+ private TunableDto setColdness(@PathVariable @NonNull final String uuidOrSlug, @PathVariable final double coldness, @NonNull final HttpServletRequest request) throws PropertyNotFound, PropertyNotWritable, PropertyTypeMismatch {
+ log.debug("setColdness: path={}", request.getServletPath());
+ return tunableService.setColdness(uuidOrSlug, coldness);
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/tunable/TunableDto.java b/src/main/java/de/ph87/home/tunable/TunableDto.java
new file mode 100644
index 0000000..6e4c0e0
--- /dev/null
+++ b/src/main/java/de/ph87/home/tunable/TunableDto.java
@@ -0,0 +1,96 @@
+package de.ph87.home.tunable;
+
+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 TunableDto implements IWebSocketMessage {
+
+ @ToString.Exclude
+ private final List