diff --git a/application.properties b/application.properties
index ded2b8e..6e96262 100644
--- a/application.properties
+++ b/application.properties
@@ -1,4 +1,6 @@
-logging.level.de.ph87.home.tvheadend.TvheadendService=DEBUG
+#logging.level.de.ph87.home.knx=DEBUG
+#logging.level.de.ph87.home.property=DEBUG
+#logging.level.de.ph87.home.tvheadend=DEBUG
#-
spring.datasource.url=jdbc:h2:./database;AUTO_SERVER=TRUE
spring.datasource.driverClassName=org.h2.Driver
diff --git a/data/G b/data/G
new file mode 100644
index 0000000..8aa03a8
--- /dev/null
+++ b/data/G
@@ -0,0 +1,237 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 810c67c..c6baab5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -44,6 +44,16 @@
org.apache.httpcomponents.client5
httpclient5
+
+ com.github.calimero
+ calimero-core
+ 2.5.1
+
+
+ org.jsoup
+ jsoup
+ 1.18.1
+
com.h2database
diff --git a/src/main/java/de/ph87/home/demo/DemoService.java b/src/main/java/de/ph87/home/demo/DemoService.java
new file mode 100644
index 0000000..7830bee
--- /dev/null
+++ b/src/main/java/de/ph87/home/demo/DemoService.java
@@ -0,0 +1,42 @@
+package de.ph87.home.demo;
+
+import de.ph87.home.device.DeviceService;
+import de.ph87.home.knx.property.KnxPropertyService;
+import de.ph87.home.knx.property.KnxPropertyType;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.event.ApplicationStartedEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import tuwien.auto.calimero.GroupAddress;
+
+@Slf4j
+@Service
+@Transactional
+@RequiredArgsConstructor
+public class DemoService {
+
+ private final KnxPropertyService knxPropertyService;
+
+ private final DeviceService deviceService;
+
+ @EventListener(ApplicationStartedEvent.class)
+ public void startup() {
+ knxPropertyService.create("fernseher", KnxPropertyType.BOOLEAN, adr(20), adr(4));
+ knxPropertyService.create("verstaerker", KnxPropertyType.BOOLEAN, adr(825), adr(824));
+ knxPropertyService.create("receiver", KnxPropertyType.BOOLEAN, adr(2561), adr(2560));
+
+ deviceService.create("EG Ambiente", "eg_ambiente", "eg_ambiente");
+ deviceService.create("Wohnzimmer Fernseher", "fernseher", "fernseher");
+ deviceService.create("Wohnzimmer Verstärker", "verstaerker", "verstaerker");
+ deviceService.create("Wohnzimmer Fensterdeko", "fensterdeko", "fensterdeko");
+ deviceService.create("Wohnzimmer Hängelampe", "haengelampe", "haengelampe");
+ deviceService.create("Receiver", "receiver", "receiver");
+ }
+
+ private static GroupAddress adr(final int rawGroupAddress) {
+ return GroupAddress.freeStyle(rawGroupAddress);
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/device/DeviceController.java b/src/main/java/de/ph87/home/device/DeviceController.java
index 1c5d9f2..e2aecd5 100644
--- a/src/main/java/de/ph87/home/device/DeviceController.java
+++ b/src/main/java/de/ph87/home/device/DeviceController.java
@@ -22,7 +22,7 @@ public class DeviceController {
@NonNull
@RequestMapping(value = "list", method = {RequestMethod.GET, RequestMethod.POST})
- private List list(@RequestBody(required = false) @Nullable final DeviceFilter filter, @NonNull final HttpServletRequest request) {
+ private List list(@RequestBody(required = false) @Nullable final DeviceFilter filter, @NonNull final HttpServletRequest request) throws PropertyTypeMismatch {
log.debug("list: path={} filter={}", request.getServletPath(), filter);
return deviceService.list(filter);
}
@@ -36,7 +36,7 @@ public class DeviceController {
@Nullable
@GetMapping("getState/{uuidOrSlug}")
- private Boolean getState(@PathVariable @NonNull final String uuidOrSlug, @NonNull final HttpServletRequest request) throws DeviceNotFound {
+ private Boolean getState(@PathVariable @NonNull final String uuidOrSlug, @NonNull final HttpServletRequest request) throws DeviceNotFound, PropertyTypeMismatch {
log.debug("getState: path={}", request.getServletPath());
return deviceService.toDto(uuidOrSlug).getStateValue();
}
diff --git a/src/main/java/de/ph87/home/device/DeviceDto.java b/src/main/java/de/ph87/home/device/DeviceDto.java
index ebf2c3b..ac473a4 100644
--- a/src/main/java/de/ph87/home/device/DeviceDto.java
+++ b/src/main/java/de/ph87/home/device/DeviceDto.java
@@ -1,6 +1,7 @@
package de.ph87.home.device;
-import de.ph87.home.property.State;
+import de.ph87.home.property.PropertyDto;
+import de.ph87.home.property.PropertyTypeMismatch;
import jakarta.annotation.Nullable;
import lombok.Getter;
import lombok.NonNull;
@@ -20,25 +21,36 @@ public class DeviceDto {
private final String slug;
@NonNull
- private final String stateProperty;
+ private final String stateConfig;
@Nullable
- private final State state;
+ @ToString.Exclude
+ private final PropertyDto state;
- public DeviceDto(@NonNull final Device device, @Nullable final State state) {
+ public DeviceDto(@NonNull final Device device, @Nullable final PropertyDto state) {
this.uuid = device.getUuid();
this.name = device.getName();
this.slug = device.getSlug();
- this.stateProperty = device.getStateProperty();
+ this.stateConfig = device.getStateProperty();
this.state = state;
}
@Nullable
- public Boolean getStateValue() {
+ @ToString.Include
+ public String state() {
+ try {
+ return "" + getStateValue();
+ } catch (PropertyTypeMismatch e) {
+ return "[PropertyTypeMismatch]";
+ }
+ }
+
+ @Nullable
+ public Boolean getStateValue() throws PropertyTypeMismatch {
if (state == null) {
return null;
}
- return state.getValue();
+ return state.getStateValueAs(Boolean.class);
}
}
diff --git a/src/main/java/de/ph87/home/device/DeviceEvent.java b/src/main/java/de/ph87/home/device/DeviceEvent.java
deleted file mode 100644
index 544c024..0000000
--- a/src/main/java/de/ph87/home/device/DeviceEvent.java
+++ /dev/null
@@ -1,21 +0,0 @@
-package de.ph87.home.device;
-
-import de.ph87.home.property.PropertyDto;
-import lombok.Getter;
-import lombok.RequiredArgsConstructor;
-import lombok.ToString;
-
-@Getter
-@ToString
-@RequiredArgsConstructor
-public class DeviceEvent {
-
- private final DeviceDto deviceDto;
-
- private final PropertyDto> propertyDto;
-
- public boolean isValueDifferent() {
- return propertyDto.isValueChanged();
- }
-
-}
diff --git a/src/main/java/de/ph87/home/device/DeviceFilter.java b/src/main/java/de/ph87/home/device/DeviceFilter.java
index 1d85d1a..734fd89 100644
--- a/src/main/java/de/ph87/home/device/DeviceFilter.java
+++ b/src/main/java/de/ph87/home/device/DeviceFilter.java
@@ -1,6 +1,7 @@
package de.ph87.home.device;
import com.fasterxml.jackson.annotation.JsonProperty;
+import de.ph87.home.property.PropertyTypeMismatch;
import jakarta.annotation.Nullable;
import lombok.Getter;
import lombok.NonNull;
@@ -23,14 +24,15 @@ public class DeviceFilter {
private Boolean stateFalse;
@SuppressWarnings("RedundantIfStatement")
- public boolean filter(@NonNull final DeviceDto dto) {
+ public boolean filter(@NonNull final DeviceDto dto) throws PropertyTypeMismatch {
if (stateNull != null && stateNull != (dto.getState() == null)) {
return false;
}
- if (stateTrue != null && (dto.getState() == null || stateTrue != dto.getState().getValue())) {
+ final Boolean value = dto.getStateValue();
+ if (stateTrue != null && (dto.getState() == null || stateTrue != value)) {
return false;
}
- if (stateFalse != null && (dto.getState() == null || stateFalse == dto.getState().getValue())) {
+ if (stateFalse != null && (dto.getState() == null || stateFalse == value)) {
return false;
}
return true;
diff --git a/src/main/java/de/ph87/home/device/DeviceService.java b/src/main/java/de/ph87/home/device/DeviceService.java
index 850c2c5..e05b6e9 100644
--- a/src/main/java/de/ph87/home/device/DeviceService.java
+++ b/src/main/java/de/ph87/home/device/DeviceService.java
@@ -2,7 +2,6 @@ package de.ph87.home.device;
import de.ph87.home.property.*;
import jakarta.annotation.Nullable;
-import jakarta.annotation.PostConstruct;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -11,6 +10,7 @@ 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;
@Slf4j
@@ -25,54 +25,57 @@ public class DeviceService {
private final ApplicationEventPublisher applicationEventPublisher;
- @PostConstruct
- public void postConstruct() {
- deviceRepository.save(new Device("EG Ambiente", "eg_ambiente", "eg_ambiente"));
- deviceRepository.save(new Device("Wohnzimmer Fernseher", "wohnzimmer_fernseher", "wohnzimmer_fernseher"));
- deviceRepository.save(new Device("Wohnzimmer Verstärker", "wohnzimmer_verstaerker", "wohnzimmer_verstaerker"));
- deviceRepository.save(new Device("Wohnzimmer Fensterdeko", "wohnzimmer_fensterdeko", "wohnzimmer_fensterdeko"));
- deviceRepository.save(new Device("Wohnzimmer Hängelampe", "wohnzimmer_haengelampe", "wohnzimmer_haengelampe"));
+ @NonNull
+ public DeviceDto create(@NonNull final String name, @NonNull final String slug, @NonNull final String stateProperty) {
+ return toDto(deviceRepository.save(new Device(name, slug, stateProperty)));
}
public void setState(@NonNull final String uuidOrSlug, final boolean state) throws DeviceNotFound, PropertyNotFound, PropertyNotWritable, PropertyTypeMismatch {
log.debug("setState: uuidOrSlug={}, state={}", uuidOrSlug, state);
- final Device device = byUuidOrSlug(uuidOrSlug);
- log.debug("setState: device={}", device);
+ final Device device = getByUuidOrSlug(uuidOrSlug);
propertyService.write(device.getStateProperty(), state, Boolean.class);
}
@NonNull
public DeviceDto toDto(final @NonNull String uuidOrSlug) throws DeviceNotFound {
- return toDto(byUuidOrSlug(uuidOrSlug));
+ return toDto(getByUuidOrSlug(uuidOrSlug));
}
@NonNull
public DeviceDto toDto(@NonNull final Device device) {
- final State state = propertyService.readSafe(device.getStateProperty(), Boolean.class);
+ final PropertyDto state = propertyService.dtoByIdAndTypeOrNull(device.getStateProperty(), Boolean.class);
return new DeviceDto(device, state);
}
@NonNull
- private Device byUuidOrSlug(@NonNull final String uuidOrSlug) throws DeviceNotFound {
+ private Device getByUuidOrSlug(@NonNull final String uuidOrSlug) throws DeviceNotFound {
return deviceRepository.findByUuidOrSlug(uuidOrSlug, uuidOrSlug).orElseThrow(() -> new DeviceNotFound("uuidOrSlug", uuidOrSlug));
}
@NonNull
- public List list(@Nullable final DeviceFilter filter) {
- return deviceRepository.findAll().stream().map(this::toDto).filter(device -> filter == null || filter.filter(device)).toList();
+ public List list(@Nullable final DeviceFilter filter) throws PropertyTypeMismatch {
+ final List all = deviceRepository.findAll().stream().map(this::toDto).toList();
+ if (filter == null) {
+ return all;
+ }
+ final List results = new ArrayList<>();
+ for (final DeviceDto dto : all) {
+ if (filter.filter(dto)) {
+ results.add(dto);
+ }
+ }
+ return results;
}
@EventListener(PropertyDto.class)
public void onPropertyChange(@NonNull final PropertyDto> dto) {
- deviceRepository.findAllByStateProperty(dto.getId())
- .forEach(device -> {
- final DeviceEvent deviceEvent = new DeviceEvent(toDto(device), dto);
- log.debug("Device updated: {}", deviceEvent.getDeviceDto());
- if (deviceEvent.isValueDifferent()) {
- log.info("Device changed: {}", deviceEvent.getDeviceDto());
- }
- applicationEventPublisher.publishEvent(deviceEvent);
- });
+ deviceRepository.findAllByStateProperty(dto.getId()).forEach(this::publish);
+ }
+
+ private void publish(@NonNull final Device device) {
+ final DeviceDto deviceDto = toDto(device);
+ log.info("Device updated: {}", deviceDto);
+ applicationEventPublisher.publishEvent(deviceDto);
}
}
diff --git a/src/main/java/de/ph87/home/dummy/DummyService.java b/src/main/java/de/ph87/home/dummy/DummyService.java
deleted file mode 100644
index 1b978aa..0000000
--- a/src/main/java/de/ph87/home/dummy/DummyService.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package de.ph87.home.dummy;
-
-import de.ph87.home.property.PropertyService;
-import de.ph87.home.property.State;
-import jakarta.annotation.PostConstruct;
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Service;
-
-@Slf4j
-@Service
-@RequiredArgsConstructor
-public class DummyService {
-
- private final PropertyService propertyService;
-
- @PostConstruct
- public void postConstruct() {
- register("eg_ambiente");
- register("wohnzimmer_fernseher");
- register("wohnzimmer_verstaerker");
- register("wohnzimmer_fensterdeko");
- register("wohnzimmer_haengelampe");
- register("receiver");
- }
-
- private void register(final String id) {
- propertyService.register(id, Boolean.class, (property, value) -> property.setState(new State<>(value)));
- }
-
-}
diff --git a/src/main/java/de/ph87/home/knx/DPT.java b/src/main/java/de/ph87/home/knx/DPT.java
new file mode 100644
index 0000000..f56a89f
--- /dev/null
+++ b/src/main/java/de/ph87/home/knx/DPT.java
@@ -0,0 +1,45 @@
+package de.ph87.home.knx;
+
+import lombok.Getter;
+import lombok.NonNull;
+import tuwien.auto.calimero.KNXException;
+import tuwien.auto.calimero.dptxlator.DPTXlator;
+import tuwien.auto.calimero.dptxlator.TranslatorTypes;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@Getter
+public class DPT {
+
+ public static final Pattern DPT_REGEX = Pattern.compile("^DPS?T-(?\\d+)(?:-(?\\d+))?$");
+
+ public final int main;
+
+ public final int sub;
+
+ public DPT(@NonNull final String dpt) throws DPTException {
+ final Matcher matcher = DPT_REGEX.matcher(dpt);
+ if (!matcher.find()) {
+ throw new DPTException(dpt);
+ }
+ try {
+ main = Integer.parseInt(matcher.group("main"));
+ final String subStr = matcher.group("sub");
+ sub = Integer.parseInt(subStr == null ? "1" : subStr);
+ } catch (NumberFormatException e) {
+ throw new DPTException(dpt, e);
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "%d.%03d".formatted(main, sub);
+ }
+
+ @NonNull
+ public DPTXlator createTranslator() throws KNXException {
+ return TranslatorTypes.createTranslator(main, toString());
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/knx/DPTException.java b/src/main/java/de/ph87/home/knx/DPTException.java
new file mode 100644
index 0000000..0f9ee10
--- /dev/null
+++ b/src/main/java/de/ph87/home/knx/DPTException.java
@@ -0,0 +1,15 @@
+package de.ph87.home.knx;
+
+import lombok.NonNull;
+
+public class DPTException extends Exception {
+
+ public DPTException(@NonNull final String dpt) {
+ super("Failed to parse DPT: " + dpt);
+ }
+
+ public DPTException(@NonNull final String dpt, @NonNull final Exception inner) {
+ super("Failed to parse DPT: " + dpt, inner);
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/knx/Group.java b/src/main/java/de/ph87/home/knx/Group.java
new file mode 100644
index 0000000..6159922
--- /dev/null
+++ b/src/main/java/de/ph87/home/knx/Group.java
@@ -0,0 +1,67 @@
+package de.ph87.home.knx;
+
+import lombok.Getter;
+import lombok.NonNull;
+import lombok.ToString;
+import tuwien.auto.calimero.GroupAddress;
+
+import java.util.Objects;
+
+@Getter
+@ToString
+public class Group {
+
+ @NonNull
+ @ToString.Exclude
+ private String id;
+
+ @NonNull
+ private final GroupAddress address;
+
+ @NonNull
+ private String name;
+
+ @NonNull
+ @ToString.Exclude
+ private String description;
+
+ @NonNull
+ private DPT dpt;
+
+ @ToString.Exclude
+ private long puid;
+
+ public Group(@NonNull final String id, @NonNull final GroupAddress address, @NonNull final String name, @NonNull final String description, @NonNull final DPT dpt, final long puid) {
+ this.id = id;
+ this.address = address;
+ this.name = name;
+ this.description = description;
+ this.dpt = dpt;
+ this.puid = puid;
+ }
+
+ @Override
+ public boolean equals(final Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof final Group group)) {
+ return false;
+ }
+ return Objects.equals(address, group.address);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(address);
+ }
+
+ public void merge(@NonNull final Group group) {
+ this.id = group.id;
+ this.name = group.name;
+ this.description = group.description;
+ this.dpt = group.dpt;
+ this.puid = group.puid;
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/knx/GroupAddressJpaConverter.java b/src/main/java/de/ph87/home/knx/GroupAddressJpaConverter.java
new file mode 100644
index 0000000..d5dbde8
--- /dev/null
+++ b/src/main/java/de/ph87/home/knx/GroupAddressJpaConverter.java
@@ -0,0 +1,31 @@
+package de.ph87.home.knx;
+
+import io.micrometer.common.lang.Nullable;
+import jakarta.persistence.AttributeConverter;
+import jakarta.persistence.Converter;
+import lombok.SneakyThrows;
+import tuwien.auto.calimero.GroupAddress;
+
+@Converter(autoApply = true)
+public class GroupAddressJpaConverter implements AttributeConverter {
+
+ @Nullable
+ @Override
+ public String convertToDatabaseColumn(@Nullable final GroupAddress groupAddress) {
+ if (groupAddress == null) {
+ return null;
+ }
+ return groupAddress.toString();
+ }
+
+ @Nullable
+ @Override
+ @SneakyThrows
+ public GroupAddress convertToEntityAttribute(@Nullable final String string) {
+ if (string == null) {
+ return null;
+ }
+ return GroupAddress.from(string);
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/knx/GroupLoaded.java b/src/main/java/de/ph87/home/knx/GroupLoaded.java
new file mode 100644
index 0000000..4f38b20
--- /dev/null
+++ b/src/main/java/de/ph87/home/knx/GroupLoaded.java
@@ -0,0 +1,16 @@
+package de.ph87.home.knx;
+
+import lombok.Getter;
+import lombok.ToString;
+
+@Getter
+@ToString
+public class GroupLoaded {
+
+ private final Group group;
+
+ public GroupLoaded(final Group group) {
+ this.group = group;
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/knx/GroupService.java b/src/main/java/de/ph87/home/knx/GroupService.java
new file mode 100644
index 0000000..3e33096
--- /dev/null
+++ b/src/main/java/de/ph87/home/knx/GroupService.java
@@ -0,0 +1,80 @@
+package de.ph87.home.knx;
+
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+import org.springframework.boot.context.event.ApplicationStartedEvent;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Service;
+import tuwien.auto.calimero.GroupAddress;
+import tuwien.auto.calimero.KNXFormatException;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class GroupService {
+
+ private final List groupList = new ArrayList<>();
+
+ private final ApplicationEventPublisher applicationEventPublisher;
+
+ @EventListener(ApplicationStartedEvent.class)
+ public void load() {
+ try {
+ final Document document = Jsoup.parse(new File("./data/G"));
+ final Elements gaList = document.select("GA");
+ gaList.stream().map(this::load).filter(Optional::isPresent).map(Optional::get).forEach(this::merge);
+ } catch (IOException e) {
+ log.error("Failed to load groups: {}", e.getMessage());
+ }
+ }
+
+ @NonNull
+ private Optional load(@NonNull final Element element) {
+ try {
+ final String id = element.attr("Id");
+ final GroupAddress address = GroupAddress.from(element.attr("Address"));
+ final String name = element.attr("Name");
+ final String description = element.attr("Description");
+ final DPT dpt = new DPT(element.attr("DatapointType"));
+ final long puid = Long.parseLong(element.attr("Puid"));
+ final Group group = new Group(id, address, name, description, dpt, puid);
+ log.info("Group loaded: {}", group);
+ return Optional.of(group);
+ } catch (KNXFormatException | DPTException e) {
+ log.error(e.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ private void merge(@NonNull Group group) {
+ synchronized (groupList) {
+ groupList.stream()
+ .filter(g -> g.equals(group))
+ .findFirst().ifPresentOrElse(
+ existing -> existing.merge(group),
+ () -> groupList.add(group)
+ );
+ }
+ applicationEventPublisher.publishEvent(new GroupLoaded(group));
+ }
+
+ @NonNull
+ public Optional findByAddress(@NonNull final GroupAddress address) {
+ synchronized (groupList) {
+ return groupList.stream().filter(g -> g.getAddress().equals(address)).findFirst();
+ }
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/knx/KnxConfig.java b/src/main/java/de/ph87/home/knx/KnxConfig.java
new file mode 100644
index 0000000..ab8024c
--- /dev/null
+++ b/src/main/java/de/ph87/home/knx/KnxConfig.java
@@ -0,0 +1,18 @@
+package de.ph87.home.knx;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "de.ph87.home.knx")
+public class KnxConfig {
+
+ private boolean enabled = true;
+
+ private String remoteAddress = "10.0.0.102";
+
+ private int remotePort = 3671;
+
+}
diff --git a/src/main/java/de/ph87/home/knx/link/KnxLinkService.java b/src/main/java/de/ph87/home/knx/link/KnxLinkService.java
new file mode 100644
index 0000000..d80a0d9
--- /dev/null
+++ b/src/main/java/de/ph87/home/knx/link/KnxLinkService.java
@@ -0,0 +1,184 @@
+package de.ph87.home.knx.link;
+
+import de.ph87.home.knx.Group;
+import de.ph87.home.knx.KnxConfig;
+import de.ph87.home.knx.link.request.Request;
+import de.ph87.home.knx.link.request.RequestRead;
+import de.ph87.home.knx.link.request.RequestWrite;
+import de.ph87.home.knx.link.router.Router;
+import jakarta.annotation.PostConstruct;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.NotImplementedException;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.stereotype.Service;
+import tuwien.auto.calimero.DetachEvent;
+import tuwien.auto.calimero.KNXException;
+import tuwien.auto.calimero.datapoint.StateDP;
+import tuwien.auto.calimero.dptxlator.DPTXlator;
+import tuwien.auto.calimero.link.KNXNetworkLinkIP;
+import tuwien.auto.calimero.link.medium.TPSettings;
+import tuwien.auto.calimero.process.ProcessCommunicatorImpl;
+import tuwien.auto.calimero.process.ProcessEvent;
+import tuwien.auto.calimero.process.ProcessListener;
+
+import java.io.IOException;
+import java.net.Inet4Address;
+import java.net.InetSocketAddress;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class KnxLinkService {
+
+ private static final Duration RESPONSE_TIMEOUT = Duration.ofMillis(500);
+
+ private final KnxConfig knxConfig;
+
+ private final ApplicationEventPublisher applicationEventPublisher;
+
+ private KNXNetworkLinkIP link = null;
+
+ private ProcessCommunicatorImpl processCommunicator = null;
+
+ private final List requests = new ArrayList<>();
+
+ @PostConstruct
+ public void postConstruct() {
+ if (!knxConfig.isEnabled()) {
+ return;
+ }
+
+ final Thread thread = new Thread(this::run, getClass().getSimpleName());
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ private void run() {
+ try {
+ while (true) {
+ execute(waitForRequest());
+ }
+ } catch (InterruptedException e) {
+ log.warn("Interrupted");
+ }
+ }
+
+ public Request waitForRequest() throws InterruptedException {
+ synchronized (requests) {
+ while (requests.isEmpty() || processCommunicator == null) {
+ if (processCommunicator == null) {
+ connect();
+ continue;
+ }
+ requests.wait();
+ }
+ return requests.removeFirst();
+ }
+ }
+
+ private void execute(@NonNull final Request request) {
+ try {
+ log.debug("Executing request: {}", request);
+ if (request instanceof final RequestRead read) {
+ processCommunicator.read(new StateDP(read.getGroup().getAddress(), ""));
+ } else if (request instanceof final RequestWrite write) {
+ processCommunicator.write(write.getGroup().getAddress(), write.getDptXlator());
+ } else {
+ throw new NotImplementedException(Request.class.getSimpleName());
+ }
+ } catch (Exception e) {
+ log.error("KnxRequest failed: request={}, error={}", request, e.getMessage());
+ processCommunicator = null;
+ }
+ }
+
+ public void queueRead(@NonNull final Group group) {
+ if (!knxConfig.isEnabled()) {
+ return;
+ }
+
+ synchronized (requests) {
+ if (requests.stream().filter(r -> r instanceof RequestRead read && read.group.equals(group)).findFirst().isEmpty()) {
+ add(new RequestRead(group));
+ }
+ }
+ }
+
+ public void queueWrite(@NonNull final Group group, @NonNull final DPTXlator dptXlator) {
+ if (!knxConfig.isEnabled()) {
+ return;
+ }
+
+ synchronized (requests) {
+ requests.stream().filter(r -> r instanceof RequestWrite write && write.getGroup().equals(group)).map(RequestWrite.class::cast).findFirst().ifPresentOrElse(
+ request -> request.update(dptXlator),
+ () -> add(new RequestWrite(group, dptXlator))
+ );
+ }
+ }
+
+ private void add(@NonNull final Request request) {
+ synchronized (requests) {
+ requests.add(request);
+ requests.notify();
+ }
+ }
+
+ public void connect() throws InterruptedException {
+ try {
+ final Inet4Address remoteAddress = (Inet4Address) Inet4Address.getByName(knxConfig.getRemoteAddress());
+ final Inet4Address localAddress = Router.getLocalInet4AddressForRemoteAddress(remoteAddress);
+ log.info("Connecting KNX link: {} -> {}", localAddress, remoteAddress);
+ link = KNXNetworkLinkIP.newTunnelingLink(new InetSocketAddress(localAddress, 0), new InetSocketAddress(remoteAddress, knxConfig.getRemotePort()), false, new TPSettings());
+ processCommunicator = new ProcessCommunicatorImpl(link);
+ processCommunicator.addProcessListener(new MyProcessListener());
+ processCommunicator.responseTimeout(RESPONSE_TIMEOUT);
+ log.info("KNX link established.");
+ synchronized (requests) {
+ requests.notify();
+ }
+ } catch (IOException | KNXException | InterruptedException e) {
+ log.error("Failed to connect KNX: {}", e.toString());
+ synchronized (requests) {
+ requests.wait(1000);
+ }
+ }
+ }
+
+ private class MyProcessListener implements ProcessListener {
+
+ @Override
+ public void groupReadRequest(@NonNull final ProcessEvent processEvent) {
+ log.debug("groupReadRequest: {}", processEvent);
+ }
+
+ @Override
+ public void groupReadResponse(@NonNull final ProcessEvent processEvent) {
+ log.debug("groupReadResponse: {}", processEvent);
+ applicationEventPublisher.publishEvent(processEvent);
+ }
+
+ @Override
+ public void groupWrite(@NonNull final ProcessEvent processEvent) {
+ log.debug("groupWrite: {}", processEvent);
+ applicationEventPublisher.publishEvent(processEvent);
+ }
+
+ @Override
+ public void detached(@NonNull final DetachEvent detachEvent) {
+ log.info("KNX link disconnected.");
+ synchronized (requests) {
+ processCommunicator = null;
+ link = null;
+ requests.notify();
+ }
+ }
+
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/knx/link/request/Request.java b/src/main/java/de/ph87/home/knx/link/request/Request.java
new file mode 100644
index 0000000..ac6aefd
--- /dev/null
+++ b/src/main/java/de/ph87/home/knx/link/request/Request.java
@@ -0,0 +1,17 @@
+package de.ph87.home.knx.link.request;
+
+import de.ph87.home.knx.Group;
+import lombok.Getter;
+import lombok.NonNull;
+
+@Getter
+public abstract class Request {
+
+ @NonNull
+ public final Group group;
+
+ public Request(@NonNull final Group group) {
+ this.group = group;
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/knx/link/request/RequestRead.java b/src/main/java/de/ph87/home/knx/link/request/RequestRead.java
new file mode 100644
index 0000000..dd03d0a
--- /dev/null
+++ b/src/main/java/de/ph87/home/knx/link/request/RequestRead.java
@@ -0,0 +1,17 @@
+package de.ph87.home.knx.link.request;
+
+import de.ph87.home.knx.Group;
+import lombok.NonNull;
+
+public class RequestRead extends Request {
+
+ public RequestRead(@NonNull final Group group) {
+ super(group);
+ }
+
+ @Override
+ public String toString() {
+ return "Read(%s, %s, %s)".formatted(group.getAddress(), group.getDpt(), group.getName());
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/knx/link/request/RequestWrite.java b/src/main/java/de/ph87/home/knx/link/request/RequestWrite.java
new file mode 100644
index 0000000..34efcca
--- /dev/null
+++ b/src/main/java/de/ph87/home/knx/link/request/RequestWrite.java
@@ -0,0 +1,28 @@
+package de.ph87.home.knx.link.request;
+
+import de.ph87.home.knx.Group;
+import lombok.Getter;
+import lombok.NonNull;
+import tuwien.auto.calimero.dptxlator.DPTXlator;
+
+@Getter
+public class RequestWrite extends Request {
+
+ @NonNull
+ private DPTXlator dptXlator;
+
+ public RequestWrite(@NonNull final Group group, @NonNull final DPTXlator dptXlator) {
+ super(group);
+ this.dptXlator = dptXlator;
+ }
+
+ public void update(@NonNull final DPTXlator dptXlator) {
+ this.dptXlator = dptXlator;
+ }
+
+ @Override
+ public String toString() {
+ return "Write(%s, %s, %s, %s)".formatted(group.getAddress(), group.getDpt(), group.getName(), dptXlator.getValue());
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/knx/link/router/Route.java b/src/main/java/de/ph87/home/knx/link/router/Route.java
new file mode 100644
index 0000000..e10dffb
--- /dev/null
+++ b/src/main/java/de/ph87/home/knx/link/router/Route.java
@@ -0,0 +1,93 @@
+package de.ph87.home.knx.link.router;
+
+import lombok.extern.slf4j.Slf4j;
+
+import java.net.Inet4Address;
+import java.net.UnknownHostException;
+import java.nio.ByteBuffer;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@Slf4j
+public class Route {
+
+ private static final Pattern regex = Pattern.compile("^([\\d.]+)\\s+([\\d.]+)\\s+([\\d.]+)\\s+(\\S+)\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)\\s+(\\S+)$");
+
+ public final Inet4Address network;
+
+ public final Inet4Address router;
+
+ public final Inet4Address netmask;
+
+ public final int metric;
+
+ public final String iface;
+
+ public final int networkInt;
+
+ public final int routerInt;
+
+ public final int netmaskInt;
+
+ public final Inet4Address addressFirst;
+
+ public final int addressFirstInt;
+
+ public final Inet4Address addressLast;
+
+ public final int addressLastInt;
+
+ public Route(final Inet4Address network, final Inet4Address router, final Inet4Address netmask, final int metric, final String iface) throws UnknownHostException {
+ this.network = network;
+ this.router = router;
+ this.netmask = netmask;
+ this.metric = metric;
+ this.iface = iface;
+
+ this.networkInt = toInt(network);
+ this.routerInt = toInt(router);
+ this.netmaskInt = toInt(netmask);
+ this.addressFirstInt = networkInt & netmaskInt;
+ this.addressLastInt = networkInt | ~netmaskInt;
+ this.addressFirst = fromInt(addressFirstInt);
+ this.addressLast = fromInt(addressLastInt);
+ }
+
+ public boolean matches(final Inet4Address address) {
+ final int addressInt = toInt(address);
+ return addressFirstInt <= addressInt && addressInt <= addressLastInt;
+ }
+
+ private static int toInt(final Inet4Address address) {
+ return ByteBuffer.wrap(address.getAddress()).getInt();
+ }
+
+ private static Inet4Address fromInt(final int value) throws UnknownHostException {
+ return (Inet4Address) Inet4Address.getByAddress(ByteBuffer.allocate(4).putInt(value).array());
+ }
+
+ public static Optional parse(final String line) {
+ final Matcher matcher = regex.matcher(line);
+ try {
+ if (matcher.matches()) {
+ final String networkString = matcher.group(1);
+ final Inet4Address network = (Inet4Address) Inet4Address.getByName(Objects.equals(networkString, "default") ? "0.0.0.0" : networkString);
+ final Inet4Address router = (Inet4Address) Inet4Address.getByName(matcher.group(2));
+ final Inet4Address netmask = (Inet4Address) Inet4Address.getByName(matcher.group(3));
+ final int metric = Integer.parseInt(matcher.group(5));
+ final String iface = matcher.group(8);
+ return Optional.of(new Route(network, router, netmask, metric, iface));
+ }
+ } catch (UnknownHostException e) {
+ log.error(e.getMessage());
+ }
+ return Optional.empty();
+ }
+
+ public boolean isDefault() {
+ return network.isAnyLocalAddress() && netmask.isAnyLocalAddress();
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/knx/link/router/Router.java b/src/main/java/de/ph87/home/knx/link/router/Router.java
new file mode 100644
index 0000000..93274a7
--- /dev/null
+++ b/src/main/java/de/ph87/home/knx/link/router/Router.java
@@ -0,0 +1,40 @@
+package de.ph87.home.knx.link.router;
+
+import java.io.IOException;
+import java.net.Inet4Address;
+import java.net.NetworkInterface;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Function;
+
+public class Router {
+
+ public static Inet4Address getLocalInet4AddressForRemoteAddress(final Inet4Address remoteAddress) throws IOException {
+ final Route route = getRoute(remoteAddress);
+ final NetworkInterface networkInterface = NetworkInterface.getByName(route.iface);
+ return (Inet4Address) networkInterface.inetAddresses().filter(address -> address instanceof Inet4Address).findFirst().orElse(null);
+ }
+
+ public static Route getRoute(final Inet4Address remoteAddress) throws IOException {
+ final List routes = execute(Route::parse, "/sbin/route", "-n");
+ final Optional routeOptional = routes.stream()
+ .filter(r -> !r.isDefault())
+ .filter(r -> r.matches(remoteAddress))
+ .reduce((a, b) -> a.metric < b.metric ? a : b)
+ .or(() -> routes.stream()
+ .filter(Route::isDefault)
+ .reduce((a, b) -> a.metric < b.metric ? a : b)
+ );
+ if (routeOptional.isEmpty()) {
+ throw new IOException("No route found for remoteAddress: " + remoteAddress);
+ }
+ return routeOptional.get();
+ }
+
+ private static List execute(final Function> parser, final String... commands) throws IOException {
+ final String output = new String(new ProcessBuilder(commands).start().getInputStream().readAllBytes());
+ return Arrays.stream(output.split("\\n")).map(parser).filter(Optional::isPresent).map(Optional::get).toList();
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/knx/property/KnxProperty.java b/src/main/java/de/ph87/home/knx/property/KnxProperty.java
new file mode 100644
index 0000000..e24fc20
--- /dev/null
+++ b/src/main/java/de/ph87/home/knx/property/KnxProperty.java
@@ -0,0 +1,44 @@
+package de.ph87.home.knx.property;
+
+import de.ph87.home.knx.GroupAddressJpaConverter;
+import jakarta.annotation.Nullable;
+import jakarta.persistence.Column;
+import jakarta.persistence.Convert;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.NonNull;
+import lombok.ToString;
+import tuwien.auto.calimero.GroupAddress;
+
+@Entity
+@Getter
+@ToString
+@NoArgsConstructor
+public class KnxProperty {
+
+ @Id
+ @NonNull
+ private String id;
+
+ @Nullable
+ @Convert(converter = GroupAddressJpaConverter.class)
+ private GroupAddress read;
+
+ @Nullable
+ @Convert(converter = GroupAddressJpaConverter.class)
+ private GroupAddress write;
+
+ @NonNull
+ @Column(nullable = false)
+ private KnxPropertyType type;
+
+ public KnxProperty(@NonNull final String id, @NonNull final KnxPropertyType type, @Nullable final GroupAddress read, @Nullable final GroupAddress write) {
+ this.id = id;
+ this.type = type;
+ this.read = read;
+ this.write = write;
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/knx/property/KnxPropertyRepository.java b/src/main/java/de/ph87/home/knx/property/KnxPropertyRepository.java
new file mode 100644
index 0000000..98d1465
--- /dev/null
+++ b/src/main/java/de/ph87/home/knx/property/KnxPropertyRepository.java
@@ -0,0 +1,13 @@
+package de.ph87.home.knx.property;
+
+import lombok.NonNull;
+import org.springframework.data.repository.ListCrudRepository;
+import tuwien.auto.calimero.GroupAddress;
+
+import java.util.List;
+
+public interface KnxPropertyRepository extends ListCrudRepository {
+
+ List findDistinctByReadOrWrite(@NonNull GroupAddress read, @NonNull GroupAddress write);
+
+}
diff --git a/src/main/java/de/ph87/home/knx/property/KnxPropertyService.java b/src/main/java/de/ph87/home/knx/property/KnxPropertyService.java
new file mode 100644
index 0000000..3fde33e
--- /dev/null
+++ b/src/main/java/de/ph87/home/knx/property/KnxPropertyService.java
@@ -0,0 +1,136 @@
+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.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 jakarta.annotation.Nullable;
+import lombok.NonNull;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.event.EventListener;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import tuwien.auto.calimero.GroupAddress;
+import tuwien.auto.calimero.KNXException;
+import tuwien.auto.calimero.dptxlator.DPTXlator;
+import tuwien.auto.calimero.dptxlator.DPTXlatorBoolean;
+import tuwien.auto.calimero.process.ProcessEvent;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+
+@Slf4j
+@Service
+@Transactional
+@EnableScheduling
+@RequiredArgsConstructor
+public class KnxPropertyService {
+
+ private final KnxPropertyRepository knxPropertyRepository;
+
+ private final PropertyService propertyService;
+
+ private final GroupService groupService;
+
+ private final KnxLinkService knxLinkService;
+
+ @Scheduled(initialDelay = 1, fixedDelay = 1, timeUnit = TimeUnit.HOURS)
+ public void readAll() {
+ knxPropertyRepository.findAll().forEach(this::read);
+ }
+
+ @EventListener(GroupLoaded.class)
+ public void onGroupLoad(@NonNull final GroupLoaded groupLoaded) {
+ findAllByAddress(groupLoaded.getGroup().getAddress()).forEach(this::read);
+ }
+
+ public void create(@NonNull final String id, @NonNull final KnxPropertyType type, @Nullable final GroupAddress read, @Nullable final GroupAddress write) {
+ final KnxProperty knxProperty = knxPropertyRepository.save(new KnxProperty(id, type, read, write));
+ updateOrCreateProperty(knxProperty);
+ }
+
+ private void updateOrCreateProperty(@NonNull final KnxProperty knxProperty) {
+ try {
+ propertyService.createOrUpdate(this, knxProperty.getId(), knxProperty.getType().clazz, (p, value) -> write(knxProperty, value));
+ read(knxProperty);
+ } catch (PropertyNotOwned e) {
+ log.error("Failed to register KnxProperty: knxProperty={}, error={}", knxProperty, e.toString());
+ }
+ }
+
+ @EventListener(ProcessEvent.class)
+ public void onProcessEvent(@NonNull final ProcessEvent event) {
+ findAllByAddress(event.getDestination()).forEach(knxProperty -> onProcessEvent(knxProperty, event));
+ }
+
+ @NonNull
+ private List findAllByAddress(@NonNull final GroupAddress address) {
+ return knxPropertyRepository.findDistinctByReadOrWrite(address, address);
+ }
+
+ 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));
+ }
+
+ private void onProcessEvent(@NonNull final KnxProperty knxProperty, @NonNull final ProcessEvent event, @NonNull final Group group) {
+ log.debug("onProcessEvent: knxProperty={}, group={}, event={}", knxProperty, group, event);
+ try {
+ final DPTXlator translator = group.getDpt().createTranslator();
+ translator.setData(event.getASDU());
+ log.debug("translator: {}", translator);
+ 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());
+ }
+ case DOUBLE -> propertyService.update(this, knxProperty.getId(), Double.class, translator.getNumericValue(), translator.getValue());
+ }
+ } catch (KNXException | PropertyNotFound | PropertyTypeMismatch | PropertyNotOwned e) {
+ log.error("Failed to handle ProcessEvent: knxProperty={}, error={}", knxProperty, e.toString());
+ }
+ }
+
+ private void read(@NonNull final KnxProperty knxProperty) {
+ final Optional property = knxPropertyRepository.findById(knxProperty.getId());
+ final Optional address = property.map(KnxProperty::getRead);
+ final Optional group = address.map(groupService::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));
+ }
+
+ private void write(@NonNull final KnxProperty knxProperty, @NonNull final Group group, @NonNull final Object value) {
+ try {
+ if (!knxProperty.getType().clazz.isInstance(value)) {
+ throw new RuntimeException("Cannot write invalid value type: type=%s, value=%s, knxProperty=%s".formatted(value.getClass(), value, knxProperty));
+ }
+ final DPTXlator translator = group.getDpt().createTranslator();
+ 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());
+ }
+ booleanTranslator.setValue((boolean) value);
+ }
+ case DOUBLE -> translator.setValue((double) value);
+ }
+ knxLinkService.queueWrite(group, translator);
+ } catch (KNXException e) {
+ log.error("Failed to write KnxProperty: knxProperty={}, error={}", knxProperty, e.toString());
+ }
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/knx/property/KnxPropertyType.java b/src/main/java/de/ph87/home/knx/property/KnxPropertyType.java
new file mode 100644
index 0000000..6334e31
--- /dev/null
+++ b/src/main/java/de/ph87/home/knx/property/KnxPropertyType.java
@@ -0,0 +1,17 @@
+package de.ph87.home.knx.property;
+
+import lombok.NonNull;
+
+public enum KnxPropertyType {
+ BOOLEAN(Boolean.class),
+ DOUBLE(Double.class),
+ ;
+
+ @NonNull
+ public final Class> clazz;
+
+ KnxPropertyType(@NonNull final Class> clazz) {
+ this.clazz = clazz;
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/property/IProperty.java b/src/main/java/de/ph87/home/property/IProperty.java
new file mode 100644
index 0000000..235bfae
--- /dev/null
+++ b/src/main/java/de/ph87/home/property/IProperty.java
@@ -0,0 +1,42 @@
+package de.ph87.home.property;
+
+import jakarta.annotation.Nullable;
+import lombok.NonNull;
+
+import java.util.Objects;
+
+public interface IProperty {
+
+ @NonNull
+ String getId();
+
+ @Nullable
+ State getState();
+
+ @NonNull
+ Class getType();
+
+ @Nullable
+ default T getStateValue() {
+ if (getState() == null) {
+ return null;
+ }
+ return getState().getValue();
+ }
+
+ @Nullable
+ default R getStateValueAs(@NonNull final Class type) throws PropertyTypeMismatch {
+ if (this.getType() != type) {
+ throw new PropertyTypeMismatch(this, type);
+ }
+ //noinspection unchecked
+ return (R) getStateValue();
+ }
+
+ @NonNull
+ default R getStateValueAs(@NonNull final Class type, @NonNull final R fallbackIfNull) throws PropertyTypeMismatch {
+ final R value = getStateValueAs(type);
+ return Objects.requireNonNullElse(value, fallbackIfNull);
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/property/Property.java b/src/main/java/de/ph87/home/property/Property.java
index 4e2ae72..4edb4a9 100644
--- a/src/main/java/de/ph87/home/property/Property.java
+++ b/src/main/java/de/ph87/home/property/Property.java
@@ -13,21 +13,39 @@ import java.util.function.Consumer;
@Getter
@ToString
@RequiredArgsConstructor
-public class Property {
+public class Property implements IProperty {
+
+ @NonNull
+ @ToString.Exclude
+ private final Object owner;
+
+ @ToString.Include
+ public String owner() {
+ return owner.getClass().getSimpleName();
+ }
@NonNull
private final String id;
@NonNull
+ @ToString.Exclude
private final Class type;
+ @ToString.Include
+ public String type() {
+ return type.getSimpleName();
+ }
+
@Nullable
+ @ToString.Exclude
private final BiConsumer, T> write;
@NonNull
+ @ToString.Exclude
private final Consumer> onStateSet;
@Nullable
+ @ToString.Exclude
private State lastState = null;
@Nullable
@@ -35,36 +53,13 @@ public class Property {
private boolean valueChanged = false;
- public void setState(@Nullable final State state) {
+ public void update(@Nullable final State state) {
this.lastState = this.state;
this.state = state;
this.valueChanged = (lastState == null) == (state == null) && (lastState == null || Objects.equals(lastState.getValue(), state.getValue()));
this.onStateSet.accept(this);
}
- @Nullable
- public T getStateValue() {
- if (state == null) {
- return null;
- }
- return state.getValue();
- }
-
- @Nullable
- public R getStateValueAs(@NonNull final Class type) throws PropertyTypeMismatch {
- if (this.type != type) {
- throw new PropertyTypeMismatch(this, type);
- }
- //noinspection unchecked
- return (R) getStateValue();
- }
-
- @NonNull
- public R getStateValueAs(@NonNull final Class type, @NonNull final R fallbackIfNull) throws PropertyTypeMismatch {
- final R value = getStateValueAs(type);
- return Objects.requireNonNullElse(value, fallbackIfNull);
- }
-
public void write(@NonNull final T value) throws PropertyNotWritable {
if (write == null) {
throw new PropertyNotWritable(this);
diff --git a/src/main/java/de/ph87/home/property/PropertyDto.java b/src/main/java/de/ph87/home/property/PropertyDto.java
index 30e1973..4217c2e 100644
--- a/src/main/java/de/ph87/home/property/PropertyDto.java
+++ b/src/main/java/de/ph87/home/property/PropertyDto.java
@@ -7,15 +7,22 @@ import lombok.ToString;
@Getter
@ToString
-public class PropertyDto {
+public class PropertyDto implements IProperty {
@NonNull
private final String id;
@NonNull
+ @ToString.Exclude
private final Class type;
+ @ToString.Include
+ public String type() {
+ return type.getSimpleName();
+ }
+
@Nullable
+ @ToString.Exclude
private final State lastState;
@Nullable
diff --git a/src/main/java/de/ph87/home/property/PropertyNotOwned.java b/src/main/java/de/ph87/home/property/PropertyNotOwned.java
new file mode 100644
index 0000000..8a05d53
--- /dev/null
+++ b/src/main/java/de/ph87/home/property/PropertyNotOwned.java
@@ -0,0 +1,11 @@
+package de.ph87.home.property;
+
+import lombok.NonNull;
+
+public class PropertyNotOwned extends Exception {
+
+ public PropertyNotOwned(@NonNull final Property> property, @NonNull final Object tryingOwner) {
+ super("Property not owned: property=%s, tryingOwner=%s".formatted(property, tryingOwner));
+ }
+
+}
diff --git a/src/main/java/de/ph87/home/property/PropertyService.java b/src/main/java/de/ph87/home/property/PropertyService.java
index 167bca6..979c27c 100644
--- a/src/main/java/de/ph87/home/property/PropertyService.java
+++ b/src/main/java/de/ph87/home/property/PropertyService.java
@@ -21,56 +21,76 @@ public class PropertyService {
private final List> propertyList = new ArrayList<>();
- @Nullable
- public State readSafe(final @NonNull String id, @NonNull final Class type) {
- try {
- return this.read(id, type);
- } catch (PropertyTypeMismatch | PropertyNotFound e) {
- log.error(e.getMessage());
- return null;
+ @SuppressWarnings("UnusedReturnValue")
+ public Property createOrUpdate(@NonNull final Object owner, @NonNull final String id, final Class type, final BiConsumer, T> write) throws PropertyNotOwned {
+ if (id.isEmpty()) {
+ throw new IllegalArgumentException("id cannot be empty");
}
- }
-
- @Nullable
- public State read(@NonNull final String id, @NonNull final Class type) throws PropertyNotFound, PropertyTypeMismatch {
- log.debug("read: id={}", id);
- return byIdAndType(id, type).getState();
- }
-
- public void write(@NonNull final String id, @NonNull final TYPE value, final Class type) throws PropertyNotFound, PropertyTypeMismatch, PropertyNotWritable {
- log.debug("write: id={}, type={}, value={}", id, value.getClass().getSimpleName(), value);
- byIdAndType(id, type).write(value);
- }
-
- @NonNull
- public Property byIdAndType(final String id, final Class type) throws PropertyNotFound, PropertyTypeMismatch {
- final Property> property = findById(id).orElseThrow(() -> new PropertyNotFound(id));
- if (type != property.getType()) {
- throw new PropertyTypeMismatch(property, type);
- }
- //noinspection unchecked
- return (Property) property;
- }
-
- @NonNull
- private Optional> findById(final @NonNull String id) {
- final Optional> optional;
synchronized (propertyList) {
- optional = propertyList.stream().filter(p -> p.getId().equals(id)).findFirst();
+ final boolean removed = findByIdAndOwner(id, owner).map(propertyList::remove).orElse(false);
+ if (propertyList.stream().anyMatch(property -> property.getId().equals(id))) {
+ throw new RuntimeException();
+ }
+ final Property property = new Property<>(owner, id, type, write, this::onStateSet);
+ propertyList.add(property);
+ if (removed) {
+ log.info("Property property: {}", property);
+ } else {
+ log.info("Property modified: {}", property);
+ }
+ return property;
+ }
+ }
+
+ public void write(@NonNull final String id, @NonNull final T value, final Class type) throws PropertyNotFound, PropertyTypeMismatch, PropertyNotWritable {
+ log.debug("write: id={}, type={}, value={}", id, value.getClass().getSimpleName(), value);
+ getByIdAndType(id, type).write(value);
+ }
+
+ @NonNull
+ public Property getByIdAndTypeAndOwner(@NonNull final String id, @NonNull final Class type, @NonNull final Object owner) throws PropertyNotFound, PropertyTypeMismatch, PropertyNotOwned {
+ final Property property = getByIdAndType(id, type);
+ if (property.getOwner() != owner) {
+ throw new PropertyNotOwned(property, owner);
+ }
+ return property;
+ }
+
+ @NonNull
+ public Optional> findByIdAndOwner(@NonNull final String id, @NonNull final Object owner) throws PropertyNotOwned {
+ final Optional> optional = findById(id);
+ if (optional.isEmpty()) {
+ return Optional.empty();
+ }
+ if (optional.get().getOwner() != owner) {
+ throw new PropertyNotOwned(optional.get(), owner);
}
return optional;
}
- @SuppressWarnings("UnusedReturnValue")
- public Property register(@NonNull final String id, final Class type, final BiConsumer, TYPE> write) {
- if (id.isEmpty()) {
- throw new RuntimeException();
- }
- final Property property = new Property<>(id, type, write, this::onStateSet);
+ @NonNull
+ public Property getByIdAndType(@NonNull final String id, @NonNull final Class type) throws PropertyNotFound, PropertyTypeMismatch {
synchronized (propertyList) {
- propertyList.add(property);
+ return findByIdAndType(id, type).orElseThrow(() -> new PropertyNotFound(id));
}
- return property;
+ }
+
+ private Optional> findByIdAndType(final String id, final Class type) throws PropertyNotFound, PropertyTypeMismatch {
+ final @NonNull Optional> optional = findById(id);
+ if (optional.isEmpty()) {
+ return Optional.empty();
+ }
+ final Property> property = optional.get();
+ if (type != property.getType()) {
+ throw new PropertyTypeMismatch(property, type);
+ }
+ //noinspection unchecked
+ return Optional.of((Property) property);
+ }
+
+ @NonNull
+ private Optional> findById(final String id) {
+ return propertyList.stream().filter(p -> p.getId().equals(id)).findFirst();
}
private void onStateSet(@NonNull final Property> property) {
@@ -87,4 +107,19 @@ public class PropertyService {
return new PropertyDto<>(property);
}
+ public void update(@NonNull final Object owner, @NonNull final String id, @NonNull final Class type, @Nullable final T value, @NonNull final String string) throws PropertyNotFound, PropertyNotOwned, PropertyTypeMismatch {
+ final Property property = getByIdAndTypeAndOwner(id, type, owner);
+ property.update(new State<>(property.getType().cast(value), string));
+ }
+
+ @Nullable
+ public PropertyDto dtoByIdAndTypeOrNull(final @NonNull String id, final Class type) {
+ try {
+ return findByIdAndType(id, type).map(this::toDto).orElse(null);
+ } catch (PropertyNotFound | PropertyTypeMismatch e) {
+ log.error(e.getMessage());
+ return null;
+ }
+ }
+
}
diff --git a/src/main/java/de/ph87/home/property/PropertyTypeMismatch.java b/src/main/java/de/ph87/home/property/PropertyTypeMismatch.java
index 93f5a23..ba25dea 100644
--- a/src/main/java/de/ph87/home/property/PropertyTypeMismatch.java
+++ b/src/main/java/de/ph87/home/property/PropertyTypeMismatch.java
@@ -7,7 +7,7 @@ import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.BAD_REQUEST)
public class PropertyTypeMismatch extends Exception {
- public PropertyTypeMismatch(@NonNull final Property> property, @NonNull final Class> type) {
+ public PropertyTypeMismatch(@NonNull final IProperty> property, @NonNull final Class> type) {
super("Property type mismatch: id=%s, expected=%s, given=%s".formatted(property.getId(), property.getType().getSimpleName(), type.getSimpleName()));
}
diff --git a/src/main/java/de/ph87/home/property/State.java b/src/main/java/de/ph87/home/property/State.java
index 6c539fc..2d28607 100644
--- a/src/main/java/de/ph87/home/property/State.java
+++ b/src/main/java/de/ph87/home/property/State.java
@@ -17,7 +17,11 @@ public class State {
@Nullable
private final T value;
- public State(@Nullable final T value) {
+ @Nullable
+ private final String string;
+
+ public State(@Nullable final T value, @Nullable final String string) {
+ this.string = string;
this.timestamp = ZonedDateTime.now();
this.value = value;
}
diff --git a/src/main/java/de/ph87/home/tvheadend/TvheadendService.java b/src/main/java/de/ph87/home/tvheadend/TvheadendService.java
index 4d35b9e..387460b 100644
--- a/src/main/java/de/ph87/home/tvheadend/TvheadendService.java
+++ b/src/main/java/de/ph87/home/tvheadend/TvheadendService.java
@@ -47,7 +47,7 @@ public class TvheadendService {
final Property receiver;
try {
- receiver = propertyService.byIdAndType(tvheadendConfig.getReceiver(), Boolean.class);
+ receiver = propertyService.getByIdAndType(tvheadendConfig.getReceiver(), Boolean.class);
} catch (PropertyNotFound | PropertyTypeMismatch e) {
log.warn("Failed to retrieve receiver Property: id={}, error={}", tvheadendConfig.getReceiver(), e.getMessage());
return;