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;