Device, Property, Dummy, Events working

This commit is contained in:
Patrick Haßel 2024-11-19 10:47:01 +01:00
commit 668c590306
21 changed files with 676 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/target/
/.idea/
/*.iml
/*.db

8
application.properties Normal file
View File

@ -0,0 +1,8 @@
logging.level.de.ph87=DEBUG
#-
spring.datasource.url=jdbc:h2:./database;AUTO_SERVER=TRUE
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
#-
spring.jpa.hibernate.ddl-auto=create

53
pom.xml Normal file
View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>de.ph87</groupId>
<artifactId>Home4</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,13 @@
package de.ph87.home;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Backend {
public static void main(String[] args) {
SpringApplication.run(Backend.class, args);
}
}

View File

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

View File

@ -0,0 +1,50 @@
package de.ph87.home.device;
import de.ph87.home.property.PropertyNotFound;
import de.ph87.home.property.PropertyNotWritable;
import de.ph87.home.property.PropertyTypeMismatch;
import jakarta.annotation.Nullable;
import jakarta.servlet.http.HttpServletRequest;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("Device")
public class DeviceController {
private final DeviceService deviceService;
@NonNull
@RequestMapping(value = "list", method = {RequestMethod.GET, RequestMethod.POST})
private List<DeviceDto> list(@RequestBody(required = false) @Nullable final DeviceFilter filter, @NonNull final HttpServletRequest request) {
log.debug("list: path={} filter={}", request.getServletPath(), filter);
return deviceService.list(filter);
}
@NonNull
@GetMapping("get/{uuidOrSlug}")
private DeviceDto get(@PathVariable @NonNull final String uuidOrSlug, @NonNull final HttpServletRequest request) throws DeviceNotFound {
log.debug("get: path={}", request.getServletPath());
return deviceService.toDto(uuidOrSlug);
}
@Nullable
@GetMapping("getState/{uuidOrSlug}")
private Boolean getState(@PathVariable @NonNull final String uuidOrSlug, @NonNull final HttpServletRequest request) throws DeviceNotFound {
log.debug("getState: path={}", request.getServletPath());
return deviceService.toDto(uuidOrSlug).getStateValue();
}
@GetMapping("setState/{uuidOrSlug}/{state}")
private void setState(@PathVariable @NonNull final String uuidOrSlug, @PathVariable final boolean state, @NonNull final HttpServletRequest request) throws PropertyNotFound, DeviceNotFound, PropertyNotWritable, PropertyTypeMismatch {
log.debug("setState: path={}", request.getServletPath());
deviceService.setState(uuidOrSlug, state);
}
}

View File

@ -0,0 +1,44 @@
package de.ph87.home.device;
import de.ph87.home.property.State;
import jakarta.annotation.Nullable;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
@Getter
@ToString
public class DeviceDto {
@NonNull
private final String uuid;
@NonNull
private final String name;
@NonNull
private final String slug;
@NonNull
private final String stateProperty;
@Nullable
private final State<Boolean> state;
public DeviceDto(@NonNull final Device device, @Nullable final State<Boolean> state) {
this.uuid = device.getUuid();
this.name = device.getName();
this.slug = device.getSlug();
this.stateProperty = device.getStateProperty();
this.state = state;
}
@Nullable
public Boolean getStateValue() {
if (state == null) {
return null;
}
return state.getValue();
}
}

View File

@ -0,0 +1,21 @@
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.isValueDifferent();
}
}

View File

@ -0,0 +1,39 @@
package de.ph87.home.device;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.annotation.Nullable;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
@Getter
@ToString
public class DeviceFilter {
@Nullable
@JsonProperty
private Boolean stateNull;
@Nullable
@JsonProperty
private Boolean stateTrue;
@Nullable
@JsonProperty
private Boolean stateFalse;
@SuppressWarnings("RedundantIfStatement")
public boolean filter(@NonNull final DeviceDto dto) {
if (stateNull != null && stateNull != (dto.getState() == null)) {
return false;
}
if (stateTrue != null && (dto.getState() == null || stateTrue != dto.getState().getValue())) {
return false;
}
if (stateFalse != null && (dto.getState() == null || stateFalse == dto.getState().getValue())) {
return false;
}
return true;
}
}

View File

@ -0,0 +1,14 @@
package de.ph87.home.device;
import lombok.NonNull;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class DeviceNotFound extends Exception {
public DeviceNotFound(@NonNull final String key, final @NonNull String value) {
super("Device not found: %s=%s".formatted(key, value));
}
}

View File

@ -0,0 +1,15 @@
package de.ph87.home.device;
import lombok.NonNull;
import org.springframework.data.repository.ListCrudRepository;
import java.util.List;
import java.util.Optional;
public interface DeviceRepository extends ListCrudRepository<Device, String> {
Optional<Device> findByUuidOrSlug(@NonNull String uuid, @NonNull String slug);
List<Device> findAllByStateProperty(@NonNull String propertyId);
}

View File

@ -0,0 +1,78 @@
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;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class DeviceService {
private final PropertyService propertyService;
private final DeviceRepository deviceRepository;
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"));
}
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);
propertyService.write(device.getStateProperty(), state);
}
@NonNull
public DeviceDto toDto(final @NonNull String uuidOrSlug) throws DeviceNotFound {
return toDto(byUuidOrSlug(uuidOrSlug));
}
@NonNull
public DeviceDto toDto(@NonNull final Device device) {
final State<Boolean> state = propertyService.readSafe(device.getStateProperty(), Boolean.class);
return new DeviceDto(device, state);
}
@NonNull
private Device byUuidOrSlug(@NonNull final String uuidOrSlug) throws DeviceNotFound {
return deviceRepository.findByUuidOrSlug(uuidOrSlug, uuidOrSlug).orElseThrow(() -> new DeviceNotFound("uuidOrSlug", uuidOrSlug));
}
@NonNull
public List<DeviceDto> list(@Nullable final DeviceFilter filter) {
return deviceRepository.findAll().stream().map(this::toDto).filter(device -> filter == null || filter.filter(device)).toList();
}
@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);
});
}
}

View File

@ -0,0 +1,30 @@
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");
}
private void register(final String id) {
propertyService.register(id, Boolean.class, (property, value) -> property.setState(new State<>(value)));
}
}

View File

@ -0,0 +1,55 @@
package de.ph87.home.property;
import jakarta.annotation.Nullable;
import lombok.Getter;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import java.util.Objects;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
@Getter
@ToString
@RequiredArgsConstructor
public class Property<T> {
@NonNull
private final String id;
@NonNull
private final Class<T> type;
@Nullable
private final BiConsumer<Property<T>, T> write;
@NonNull
private final Consumer<Property<T>> onStateSet;
@Nullable
private State<T> lastState = null;
@Nullable
private State<T> state = null;
private boolean valueDifferent = false;
public void setState(@Nullable final State<T> state) {
this.lastState = this.state;
this.state = state;
this.valueDifferent = (lastState == null) == (state == null) && (lastState == null || Objects.equals(lastState.getValue(), state.getValue()));
this.onStateSet.accept(this);
}
public void write(@NonNull final Object value) throws PropertyNotWritable, PropertyTypeMismatch {
if (!type.isInstance(value)) {
throw new PropertyTypeMismatch(this, value);
}
if (write == null) {
throw new PropertyNotWritable(this);
}
write.accept(this, type.cast(value));
}
}

View File

@ -0,0 +1,34 @@
package de.ph87.home.property;
import jakarta.annotation.Nullable;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
@Getter
@ToString
public class PropertyDto<T> {
@NonNull
private final String id;
@NonNull
private final Class<T> type;
@Nullable
private final State<T> lastState;
@Nullable
private final State<T> state;
private final boolean valueDifferent;
public PropertyDto(@NonNull final Property<T> property) {
this.id = property.getId();
this.type = property.getType();
this.state = property.getState();
this.lastState = property.getLastState();
this.valueDifferent = property.isValueDifferent();
}
}

View File

@ -0,0 +1,14 @@
package de.ph87.home.property;
import lombok.NonNull;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class PropertyNotFound extends Exception {
public PropertyNotFound(@NonNull final String id) {
super("Property not found: id=" + id);
}
}

View File

@ -0,0 +1,14 @@
package de.ph87.home.property;
import lombok.NonNull;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.BAD_REQUEST)
public class PropertyNotWritable extends Exception {
public PropertyNotWritable(@NonNull final Property<?> property) {
super("Property not writable: id=" + property.getId());
}
}

View File

@ -0,0 +1,98 @@
package de.ph87.home.property;
import jakarta.annotation.Nullable;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.BiConsumer;
@Slf4j
@Service
@RequiredArgsConstructor
public class PropertyService {
private final ApplicationEventPublisher applicationEventPublisher;
private final List<Property<?>> propertyList = new ArrayList<>();
@Nullable
public <TYPE> State<TYPE> readSafe(final @NonNull String id, @NonNull final Class<TYPE> type) {
try {
return this.read(id, type);
} catch (PropertyTypeMismatch | PropertyNotFound e) {
log.error(e.getMessage());
return null;
}
}
@Nullable
public <TYPE> State<TYPE> read(@NonNull final String id, @NonNull final Class<TYPE> type) throws PropertyNotFound, PropertyTypeMismatch {
log.debug("read: id={}", id);
final Property<?> property = byIdAndType(id, type);
if (property.getState() == null) {
return null;
}
if (type.isInstance(property.getState().getValue())) {
//noinspection unchecked
return (State<TYPE>) property.getState();
}
throw new PropertyTypeMismatch(property, type);
}
public void write(@NonNull final String id, @NonNull final Object value) throws PropertyNotFound, PropertyTypeMismatch, PropertyNotWritable {
log.debug("write: id={}, type={}, value={}", id, value.getClass().getSimpleName(), value);
final Property<?> property = byIdAndType(id, value.getClass());
property.write(value);
}
@NonNull
private <TYPE> Property<?> byIdAndType(final String id, final Class<TYPE> type) throws PropertyNotFound, PropertyTypeMismatch {
final Property<?> property = findById(id).orElseThrow(() -> new PropertyNotFound(id));
if (type != property.getType()) {
throw new PropertyTypeMismatch(property, type);
}
return property;
}
@NonNull
private Optional<Property<?>> findById(final @NonNull String id) {
final Optional<Property<?>> optional;
synchronized (propertyList) {
optional = propertyList.stream().filter(p -> p.getId().equals(id)).findFirst();
}
return optional;
}
@SuppressWarnings("UnusedReturnValue")
public <TYPE> Property<TYPE> register(@NonNull final String id, final Class<TYPE> type, final BiConsumer<Property<TYPE>, TYPE> write) {
if (id.isEmpty()) {
throw new RuntimeException();
}
final Property<TYPE> property = new Property<>(id, type, write, this::onStateSet);
synchronized (propertyList) {
propertyList.add(property);
}
return property;
}
private void onStateSet(@NonNull final Property<?> property) {
final PropertyDto<?> dto = toDto(property);
log.debug("Property updated: {}", dto);
if (dto.isValueDifferent()) {
log.info("Property changed: {}", dto);
}
applicationEventPublisher.publishEvent(dto);
}
@NonNull
private <T> PropertyDto<T> toDto(@NonNull final Property<T> property) {
return new PropertyDto<>(property);
}
}

View File

@ -0,0 +1,18 @@
package de.ph87.home.property;
import lombok.NonNull;
import org.springframework.http.HttpStatus;
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) {
super("Property type mismatch: id=%s, expected=%s, given=%s".formatted(property.getId(), property.getType().getSimpleName(), type.getSimpleName()));
}
public PropertyTypeMismatch(@NonNull final Property<?> property, @NonNull final Object value) {
super("Property type mismatch: id=%s, expected=%s, given=%s, value=%s".formatted(property.getId(), property.getType().getSimpleName(), value.getClass().getSimpleName(), value));
}
}

View File

@ -0,0 +1,25 @@
package de.ph87.home.property;
import jakarta.annotation.Nullable;
import lombok.Getter;
import lombok.NonNull;
import lombok.ToString;
import java.time.ZonedDateTime;
@Getter
@ToString
public class State<T> {
@NonNull
private final ZonedDateTime timestamp;
@Nullable
private final T value;
public State(@Nullable final T value) {
this.timestamp = ZonedDateTime.now();
this.value = value;
}
}

View File

@ -0,0 +1,10 @@
logging.level.root=WARN
logging.level.de.ph87=INFO
#-
spring.jpa.hibernate.naming.implicit-strategy=org.hibernate.boot.model.naming.ImplicitNamingStrategyComponentPathImpl
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false
#-
spring.jackson.serialization.indent_output=true
#-
spring.main.banner-mode=off