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 extends ControlState extends Control>>> SUPPORTED_CONTROLS_STATE_MAP;
+ private static final Map<
+ Class extends Control>,
+ Class extends ControlState, ? extends Control>>> 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 extends ControlState extends Control>>> getSupportedControlsStateMap() {
+ Map, Class extends ControlState, ? extends Control>>> 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")
+ }
+}