diff --git a/src/main/java/cz/smarteon/loxone/app/state/AnalogInfoControlState.java b/src/main/java/cz/smarteon/loxone/app/state/AnalogInfoControlState.java index c719fb9..5a5a107 100644 --- a/src/main/java/cz/smarteon/loxone/app/state/AnalogInfoControlState.java +++ b/src/main/java/cz/smarteon/loxone/app/state/AnalogInfoControlState.java @@ -10,15 +10,7 @@ /** * State class for keeping state of a AnalogInfoControl. */ -public class AnalogInfoControlState extends ControlState { - - /** - * Current value of the AnalogInfoControl. - */ - @Getter - @Nullable - private Double value; - +public class AnalogInfoControlState extends ControlState { public AnalogInfoControlState(Loxone loxone, AnalogInfoControl control) { super(loxone, control); @@ -30,17 +22,8 @@ public AnalogInfoControlState(Loxone loxone, AnalogInfoControl control) { */ @Override void accept(@NotNull ValueEvent event) { - super.accept(event); - if (event.getUuid().equals(control.stateValue())) { - processValueEvent(event); + if (event.getUuid().equals(getControl().stateValue())) { + setState(event.getValue()); } } - - /** - * Process the ValueEvent as a value event message and update the state of the control accordingly. - * @param event value event received - */ - private void processValueEvent(ValueEvent event) { - value = event.getValue(); - } } diff --git a/src/main/java/cz/smarteon/loxone/app/state/ControlState.java b/src/main/java/cz/smarteon/loxone/app/state/ControlState.java index ece8e71..d4b43d8 100644 --- a/src/main/java/cz/smarteon/loxone/app/state/ControlState.java +++ b/src/main/java/cz/smarteon/loxone/app/state/ControlState.java @@ -4,29 +4,68 @@ import cz.smarteon.loxone.app.Control; import cz.smarteon.loxone.message.TextEvent; import cz.smarteon.loxone.message.ValueEvent; +import lombok.AccessLevel; +import lombok.Getter; import lombok.RequiredArgsConstructor; +import lombok.Setter; import org.jetbrains.annotations.NotNull; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + /** * Base class for all the controlStates in loxone application. - * @param The type of control this class keeps trakc of the state. + *

+ * This class keeps track of the state of the control based on the events of the miniserver. + *

+ * @param The type of the state. + * @param The type of control this class keeps track of. */ -@RequiredArgsConstructor -public abstract class ControlState { +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class ControlState { /** * The webSocket connection to communicate with the loxone miniserver. */ - protected final Loxone loxone; + @Getter + private final Loxone loxone; /** * The control that this state refers to. */ - protected final T control; + @Getter + private final T control; + + /** + * The current state of the control. + */ + @Getter + private S state; + + /** + * The set of listeners registered for this control state. + */ + private final Set> listeners = new CopyOnWriteArraySet<>(); + + /** + * Registers a listener to be notified of state changes. + * @param listener the listener to register (should not be null). + */ + public void registerListener(@NotNull ControlStateListener listener) { + listeners.add(listener); + } + + /** + * Unregisters a previously registered listener. + * @param listener the listener to unregister (should not be null). + */ + public void unregisterListener(@NotNull ControlStateListener listener) { + listeners.remove(listener); + } /** * Method that accepts ValueEvent from the miniserver to update the internal state. - * @param event value event received (should not be null) + * @param event value event received (should not be null). */ void accept(@NotNull ValueEvent event) { // default implementation @@ -34,9 +73,26 @@ void accept(@NotNull ValueEvent event) { /** * Method that accepts TextEvent from the miniserver to update the internal state. - * @param event text event received (should not be null) + * @param event text event received (should not be null). */ void accept(@NotNull TextEvent event) { // default implementation } + + void setState(@NotNull S state) { + boolean stateChanged = this.state == null || !this.state.equals(state); + this.state = state; + if (stateChanged) { + notifyStateChanged(); + } + } + + /** + * Notifies all registered listeners of a state change. + */ + void notifyStateChanged() { + for (ControlStateListener listener : listeners) { + listener.onStateChange(this); + } + } } diff --git a/src/main/java/cz/smarteon/loxone/app/state/ControlStateListener.java b/src/main/java/cz/smarteon/loxone/app/state/ControlStateListener.java new file mode 100644 index 0000000..fd4923b --- /dev/null +++ b/src/main/java/cz/smarteon/loxone/app/state/ControlStateListener.java @@ -0,0 +1,17 @@ +package cz.smarteon.loxone.app.state; + +import cz.smarteon.loxone.app.Control; +import org.jetbrains.annotations.NotNull; + +/** + * Allows reacting on control state changes. + * @param The type of control this listener is interested in. + */ +public interface ControlStateListener { + + /** + * Called when an event is received and processed by the control state and the state of the control has changed. + * @param controlState the control state that received the event (should not be null) + */ + void onStateChange(@NotNull ControlState controlState); +} diff --git a/src/main/java/cz/smarteon/loxone/app/state/DigitalInfoControlState.java b/src/main/java/cz/smarteon/loxone/app/state/DigitalInfoControlState.java index a5a1059..9350887 100644 --- a/src/main/java/cz/smarteon/loxone/app/state/DigitalInfoControlState.java +++ b/src/main/java/cz/smarteon/loxone/app/state/DigitalInfoControlState.java @@ -10,15 +10,7 @@ /** * State class for keeping state of a DigitalInfoControl. */ -public class DigitalInfoControlState extends ControlState { - - /** - * Current value of the DigitalInfoControl. - */ - @Getter - @Nullable - private Boolean state; - +public class DigitalInfoControlState extends ControlState { public DigitalInfoControlState(Loxone loxone, DigitalInfoControl control) { super(loxone, control); @@ -30,17 +22,8 @@ public DigitalInfoControlState(Loxone loxone, DigitalInfoControl control) { */ @Override void accept(@NotNull ValueEvent event) { - super.accept(event); - if (event.getUuid().equals(control.stateActive())) { - processActiveEvent(event); + if (event.getUuid().equals(getControl().stateActive())) { + setState(event.getValue() == 1); } } - - /** - * Process the ValueEvent as an active state event message and update the state of the control accordingly. - * @param event value event received - */ - private void processActiveEvent(ValueEvent event) { - state = event.getValue() == 1; - } } diff --git a/src/main/java/cz/smarteon/loxone/app/state/LockableControlState.java b/src/main/java/cz/smarteon/loxone/app/state/LockableControlState.java index e53c621..769e24c 100644 --- a/src/main/java/cz/smarteon/loxone/app/state/LockableControlState.java +++ b/src/main/java/cz/smarteon/loxone/app/state/LockableControlState.java @@ -6,6 +6,7 @@ import cz.smarteon.loxone.app.state.events.LockedEvent; import cz.smarteon.loxone.message.TextEvent; import lombok.Getter; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -14,20 +15,23 @@ /** * Base class for the controlStates in loxone application that supports locking. - * @param The type of control this class keeps trakc of the state. + *

+ * This class keeps track of the state of the control based on the events of the miniserver. + *

+ * @param The type of control this class keeps track of. */ @Slf4j -public abstract class LockableControlState extends ControlState { +public abstract class LockableControlState extends ControlState { /** - * Current state of the lock of the control. + * Current state of the lock. */ @Getter @Nullable private Locked locked; /** - * Extra free format reason in case of lock of the control. + * Extra free format reason in case of lock. */ @Getter @Nullable @@ -43,7 +47,7 @@ protected LockableControlState(Loxone loxone, T control) { */ @Override void accept(@NotNull TextEvent event) { - if (event.getUuid().equals(control.stateLocked())) { + if (event.getUuid().equals(getControl().stateLocked())) { processLockedEvent(event); } } @@ -54,14 +58,20 @@ void accept(@NotNull TextEvent event) { */ private void processLockedEvent(TextEvent event) { try { + final boolean isCurrentState; if (event.getText().isEmpty()) { + isCurrentState = Locked.NO.equals(locked) && lockedReason == null; this.locked = Locked.NO; this.lockedReason = null; - return; + } else { + final LockedEvent lockedEvent = Codec.readMessage(event.getText(), LockedEvent.class); + isCurrentState = lockedEvent.getLocked().equals(locked) && lockedEvent.getReason().equals(lockedReason); + this.locked = lockedEvent.getLocked(); + this.lockedReason = lockedEvent.getReason(); + } + if (!isCurrentState) { + notifyStateChanged(); } - final LockedEvent lockedEvent = Codec.readMessage(event.getText(), LockedEvent.class); - this.locked = lockedEvent.getLocked(); - this.lockedReason = lockedEvent.getReason(); } catch (IOException e) { log.info("Unable to parse locked event!", e); } diff --git a/src/main/java/cz/smarteon/loxone/app/state/LoxoneLockedException.java b/src/main/java/cz/smarteon/loxone/app/state/LoxoneLockedException.java new file mode 100644 index 0000000..95c8b53 --- /dev/null +++ b/src/main/java/cz/smarteon/loxone/app/state/LoxoneLockedException.java @@ -0,0 +1,10 @@ +package cz.smarteon.loxone.app.state; + +import cz.smarteon.loxone.LoxoneException; + +public class LoxoneLockedException extends LoxoneException { + + public LoxoneLockedException(String message) { + super(message); + } +} diff --git a/src/main/java/cz/smarteon/loxone/app/state/LoxoneState.java b/src/main/java/cz/smarteon/loxone/app/state/LoxoneState.java index e24af35..bfd15df 100644 --- a/src/main/java/cz/smarteon/loxone/app/state/LoxoneState.java +++ b/src/main/java/cz/smarteon/loxone/app/state/LoxoneState.java @@ -29,13 +29,15 @@ @Slf4j public class LoxoneState implements LoxoneAppListener, LoxoneEventListener { - private static final Map, Class>> SUPPORTED_CONTROLS_STATE_MAP; + private static final Map< + Class, + Class>> SUPPORTED_CONTROLS_STATE_MAP; private final Loxone loxone; private LoxoneApp loxoneApp; - private Map> controlStates; + private Map> controlStates; static { SUPPORTED_CONTROLS_STATE_MAP = new HashMap<>(); @@ -59,12 +61,12 @@ public LoxoneState(@NotNull Loxone loxone) { } @TestOnly - Map, Class>> getSupportedControlsStateMap() { + Map, Class>> getSupportedControlsStateMap() { return SUPPORTED_CONTROLS_STATE_MAP; } @TestOnly - Map> getControlStates() { + Map> getControlStates() { return controlStates; } @@ -103,7 +105,7 @@ private void initializeState() { Map.Entry::getKey, e -> { try { - return (ControlState) SUPPORTED_CONTROLS_STATE_MAP.get(e.getValue().getClass()) + return (ControlState) SUPPORTED_CONTROLS_STATE_MAP.get(e.getValue().getClass()) .getConstructors()[0].newInstance(loxone, e.getValue()); } catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException ex) { diff --git a/src/main/java/cz/smarteon/loxone/app/state/SwitchControlState.java b/src/main/java/cz/smarteon/loxone/app/state/SwitchControlState.java index 7334c6c..618fad2 100644 --- a/src/main/java/cz/smarteon/loxone/app/state/SwitchControlState.java +++ b/src/main/java/cz/smarteon/loxone/app/state/SwitchControlState.java @@ -12,14 +12,7 @@ /** * State class for keeping and managing state of a SwitchControl. */ -public class SwitchControlState extends LockableControlState { - - /** - * Current state of the SwitchControl. - */ - @Getter - @Nullable - private Boolean state; +public class SwitchControlState extends LockableControlState { public SwitchControlState(Loxone loxone, SwitchControl control) { super(loxone, control); @@ -29,7 +22,7 @@ public SwitchControlState(Loxone loxone, SwitchControl control) { * Toggles state of SwitchControl. When current state is SwitchState.UNINITIALIZED it switches to On. */ public void toggleState() { - if (Boolean.TRUE.equals(state)) { + if (Boolean.TRUE.equals(getState())) { stateOff(); } else { stateOn(); @@ -40,7 +33,10 @@ public void toggleState() { * Sets state of SwitchControl to On. */ public void stateOn() { - loxone.sendControlCommand(control, switchControl -> genericControlCommand(switchControl.getUuid().toString(), + if (getLocked() != null && !Locked.NO.equals(getLocked())) { + throw new LoxoneLockedException("SwitchControl is locked, so no state change is possible"); + } + getLoxone().sendControlCommand(getControl(), switchControl -> genericControlCommand(switchControl.getUuid().toString(), "On")); } @@ -48,7 +44,10 @@ public void stateOn() { * Sets state of SwitchControl to Off. */ public void stateOff() { - loxone.sendControlCommand(control, switchControl -> genericControlCommand(switchControl.getUuid().toString(), + if (getLocked() != null && !Locked.NO.equals(getLocked())) { + throw new LoxoneLockedException("SwitchControl is locked, so no state change is possible"); + } + getLoxone().sendControlCommand(getControl(), switchControl -> genericControlCommand(switchControl.getUuid().toString(), "Off")); } @@ -58,17 +57,8 @@ public void stateOff() { */ @Override void accept(@NotNull ValueEvent event) { - super.accept(event); - if (event.getUuid().equals(control.stateActive())) { - processActiveEvent(event); + if (event.getUuid().equals(getControl().stateActive())) { + setState(event.getValue() == 1); } } - - /** - * Process the ValueEvent as an active state event message and update the state of the control accordingly. - * @param event value event received - */ - private void processActiveEvent(ValueEvent event) { - state = event.getValue() == 1; - } } diff --git a/src/test/kotlin/app/state/AnalogInfoControlStateTest.kt b/src/test/kotlin/app/state/AnalogInfoControlStateTest.kt index af7d0d6..81a01d5 100644 --- a/src/test/kotlin/app/state/AnalogInfoControlStateTest.kt +++ b/src/test/kotlin/app/state/AnalogInfoControlStateTest.kt @@ -38,12 +38,12 @@ class AnalogInfoControlStateTest { val analogInfoControlState = AnalogInfoControlState(loxone, analogInfoControl) expectThat(analogInfoControlState) { - get { value }.isNull() + get { state }.isNull() testParams.startingValue?.let { analogInfoControlState.accept(ValueEvent(analogInfoControl.stateValue(), testParams.startingValue)) } analogInfoControlState.accept(ValueEvent(LoxoneUuid(testParams.uuid), testParams.newValue)) - get { value }.isEqualTo(testParams.expectedValue) + get { state }.isEqualTo(testParams.expectedValue) } } @@ -55,13 +55,13 @@ class AnalogInfoControlStateTest { } val analogInfoControlState = AnalogInfoControlState(loxone, analogInfoControl); - expectThat(analogInfoControlState.value).isNull() + expectThat(analogInfoControlState.state).isNull() analogInfoControlState.accept( TextEvent( LoxoneUuid("0f869a64-028d-0cc2-ffffd4c75dbaf53c"), LoxoneUuid("0f869a64-028d-0cc2-ffffd4c75dbaf53d"), "value" ) ) - expectThat(analogInfoControlState.value).isNull() + expectThat(analogInfoControlState.state).isNull() } } diff --git a/src/test/kotlin/app/state/ControlStateListenerTest.kt b/src/test/kotlin/app/state/ControlStateListenerTest.kt new file mode 100644 index 0000000..7c62872 --- /dev/null +++ b/src/test/kotlin/app/state/ControlStateListenerTest.kt @@ -0,0 +1,53 @@ +package cz.smarteon.loxone.app.state + +import cz.smarteon.loxone.Loxone +import cz.smarteon.loxone.LoxoneUuid +import cz.smarteon.loxone.app.DigitalInfoControl +import cz.smarteon.loxone.message.ValueEvent +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import strikt.api.expectThat +import strikt.assertions.isEqualTo + +class ControlStateListenerTest { + + @Test + fun `listener notified only on state change and respects unregister`() { + val loxone = mockk() + val control = mockk { + every { stateActive() } returns LoxoneUuid("11111111-1111-1111-1111111111111111") + } + val state = DigitalInfoControlState(loxone, control) + + var notifications = 0 + val listener = object : ControlStateListener { + override fun onStateChange(controlState: ControlState) { + notifications++ + } + } + + state.registerListener(listener) + + // Unrelated UUID -> no notify + state.accept(ValueEvent(LoxoneUuid("11111111-1111-1111-11111111111111FF"), 1.0)) + expectThat(notifications).isEqualTo(0) + + // First change from null -> true -> notify + state.accept(ValueEvent(control.stateActive(), 1.0)) + expectThat(notifications).isEqualTo(1) + + // Same value again -> no state change -> no notify + state.accept(ValueEvent(control.stateActive(), 1.0)) + expectThat(notifications).isEqualTo(1) + + // Value change true -> false -> notify + state.accept(ValueEvent(control.stateActive(), 0.0)) + expectThat(notifications).isEqualTo(2) + + // Unregister and change again -> no further notifications + state.unregisterListener(listener) + state.accept(ValueEvent(control.stateActive(), 1.0)) + expectThat(notifications).isEqualTo(2) + } +} diff --git a/src/test/kotlin/app/state/LockableControlStateTest.kt b/src/test/kotlin/app/state/LockableControlStateTest.kt new file mode 100644 index 0000000..41352fb --- /dev/null +++ b/src/test/kotlin/app/state/LockableControlStateTest.kt @@ -0,0 +1,101 @@ +package cz.smarteon.loxone.app.state + +import cz.smarteon.loxone.Loxone +import cz.smarteon.loxone.LoxoneUuid +import cz.smarteon.loxone.app.SwitchControl +import cz.smarteon.loxone.message.TextEvent +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import strikt.api.expectThat +import strikt.assertions.isEqualTo +import strikt.assertions.isNull + +class LockableControlStateTest { + + @Test + fun `should set locked NO on empty text event`() { + val loxone = mockk() + val switchControl = mockk { + every { stateLocked() } returns LoxoneUuid("0f869a64-028d-0cc2-ffffd4c75dbaf53e") + } + val state = SwitchControlState(loxone, switchControl) + + expectThat(state.locked).isNull() + expectThat(state.lockedReason).isNull() + + state.accept( + TextEvent( + requireNotNull(switchControl.stateLocked()), + LoxoneUuid("11111111-1111-1111-1111111111111111"), + "" + ) + ) + + expectThat(state.locked).isEqualTo(Locked.NO) + expectThat(state.lockedReason).isNull() + } + + @Test + fun `should parse locked JSON and update state`() { + val loxone = mockk() + val switchControl = mockk { + every { stateLocked() } returns LoxoneUuid("0f869a64-028d-0cc2-ffffd4c75dbaf53e") + } + val state = SwitchControlState(loxone, switchControl) + + state.accept( + TextEvent( + requireNotNull(switchControl.stateLocked()), + LoxoneUuid("11111111-1111-1111-1111111111111111"), + "{" + + "\"locked\": \"UI\", " + + "\"reason\": \"manual lock\"" + + "}" + ) + ) + + expectThat(state.locked).isEqualTo(Locked.UI) + expectThat(state.lockedReason).isEqualTo("manual lock") + } + + @Test + fun `should ignore unrelated text events`() { + val loxone = mockk() + val switchControl = mockk { + every { stateLocked() } returns LoxoneUuid("0f869a64-028d-0cc2-ffffd4c75dbaf53e") + } + val state = SwitchControlState(loxone, switchControl) + + state.accept( + TextEvent( + LoxoneUuid("99999999-9999-9999-9999999999999999"), + LoxoneUuid("11111111-1111-1111-1111111111111111"), + "{\"locked\": \"UI\"}" + ) + ) + + expectThat(state.locked).isNull() + expectThat(state.lockedReason).isNull() + } + + @Test + fun `should handle malformed JSON without changing state`() { + val loxone = mockk() + val switchControl = mockk { + every { stateLocked() } returns LoxoneUuid("0f869a64-028d-0cc2-ffffd4c75dbaf53e") + } + val state = SwitchControlState(loxone, switchControl) + + state.accept( + TextEvent( + requireNotNull(switchControl.stateLocked()), + LoxoneUuid("11111111-1111-1111-1111111111111111"), + "{not a json" + ) + ) + + expectThat(state.locked).isNull() + expectThat(state.lockedReason).isNull() + } +} diff --git a/src/test/kotlin/app/state/LoxoneStateTest.kt b/src/test/kotlin/app/state/LoxoneStateTest.kt index 4717f1f..78fc901 100644 --- a/src/test/kotlin/app/state/LoxoneStateTest.kt +++ b/src/test/kotlin/app/state/LoxoneStateTest.kt @@ -42,7 +42,7 @@ class LoxoneStateTest { @Test fun `controlStates should be compatible`() { expectThat(loxoneState.supportedControlsStateMap.entries).all { - get { value.getDeclaredConstructor(loxone.javaClass, key) }.isNotNull() + get { value.getDeclaredConstructor(Loxone::class.java, key) }.isNotNull() } } diff --git a/src/test/kotlin/app/state/SwitchControlStateLockedTest.kt b/src/test/kotlin/app/state/SwitchControlStateLockedTest.kt new file mode 100644 index 0000000..07a6494 --- /dev/null +++ b/src/test/kotlin/app/state/SwitchControlStateLockedTest.kt @@ -0,0 +1,100 @@ +package cz.smarteon.loxone.app.state + +import cz.smarteon.loxone.Loxone +import cz.smarteon.loxone.LoxoneUuid +import cz.smarteon.loxone.app.SwitchControl +import cz.smarteon.loxone.message.TextEvent +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import strikt.api.expectThat +import strikt.assertions.isEqualTo +import strikt.assertions.isNull + +class SwitchControlStateLockedTest { + + @Test + fun `should not update locked when control has no locked state`() { + val loxone = mockk() + val control = mockk { + every { stateLocked() } returns null + } + val state = SwitchControlState(loxone, control) + + // Precondition + expectThat(state.locked).isNull() + expectThat(state.lockedReason).isNull() + + // Send any text event; since control has no locked state UUID, it must be ignored + state.accept( + TextEvent( + LoxoneUuid("aaaaaaaa-bbbb-cccc-eeeeeeeeeeeeeeee"), + LoxoneUuid("11111111-1111-1111-1111111111111111"), + "{\"locked\": \"UI\", \"reason\": \"test\"}" + ) + ) + + // Still unchanged + expectThat(state.locked).isNull() + expectThat(state.lockedReason).isNull() + } + + @Test + fun `should not update locked when event uuid does not match control's locked state uuid`() { + val loxone = mockk() + val lockedUuid = LoxoneUuid("0f869a64-028d-0cc2-ffffd4c75dbaf53e") + val control = mockk { + every { stateLocked() } returns lockedUuid + } + val state = SwitchControlState(loxone, control) + + // Precondition + expectThat(state.locked).isNull() + expectThat(state.lockedReason).isNull() + + // Send text event with a different UUID than control.stateLocked() + state.accept( + TextEvent( + LoxoneUuid("aaaaaaaa-bbbb-cccc-eeeeeeeeeeeeeeee"), // different from lockedUuid + LoxoneUuid("11111111-1111-1111-1111111111111111"), + "{\"locked\": \"UI\", \"reason\": \"no-op\"}" + ) + ) + + // Still unchanged + expectThat(state.locked).isNull() + expectThat(state.lockedReason).isNull() + } + + @Test + fun `should update locked when control has locked state and event uuid matches`() { + val loxone = mockk() + val lockedUuid = LoxoneUuid("0f869a64-028d-0cc2-ffffd4c75dbaf53e") + val control = mockk { + every { stateLocked() } returns lockedUuid + } + val state = SwitchControlState(loxone, control) + + // Apply empty text first -> sets to NO + state.accept( + TextEvent( + requireNotNull(control.stateLocked()), + LoxoneUuid("bbbbbbbb-cccc-dddd-ffffffffffffffff"), + "" + ) + ) + expectThat(state.locked).isEqualTo(Locked.NO) + expectThat(state.lockedReason).isNull() + + // Apply JSON -> sets to UI with reason + state.accept( + TextEvent( + requireNotNull(control.stateLocked()), + LoxoneUuid("bbbbbbbb-cccc-dddd-ffffffffffffffff"), + "{\"locked\": \"UI\", \"reason\": \"via api\"}" + ) + ) + expectThat(state.locked).isEqualTo(Locked.UI) + expectThat(state.lockedReason).isEqualTo("via api") + } +}