From b5c0d21f2331b8de7676b8c4a4c48266ea048734 Mon Sep 17 00:00:00 2001 From: Jonathan Gilbert Date: Wed, 30 Oct 2024 10:49:33 +1100 Subject: [PATCH] Add Support for AWS MQTT based IAqualink devices, such as Zodiac Hydroxinator Signed-off-by: Jonathan Gilbert --- bundles/org.openhab.binding.iaqualink/pom.xml | 51 +- .../internal/IAqualinkBindingConstants.java | 6 + .../internal/IAqualinkHandlerFactory.java | 10 +- .../{ => v1}/api/IAqualinkClient.java | 18 +- .../{ => v1}/api/dto/AccountInfo.java | 2 +- .../internal/{ => v1}/api/dto/Auxiliary.java | 2 +- .../internal/{ => v1}/api/dto/Device.java | 2 +- .../internal/{ => v1}/api/dto/Home.java | 2 +- .../internal/{ => v1}/api/dto/OneTouch.java | 2 +- .../internal/{ => v1}/api/dto/SignIn.java | 2 +- .../{ => v1}/handler/AuxiliaryType.java | 2 +- .../{ => v1}/handler/HeaterState.java | 2 +- .../{ => v1}/handler/IAqualinkHandler.java | 16 +- .../internal/v2/IAqualinkV2Handler.java | 452 ++++++++++++++++ .../internal/v2/api/IAqualinkClient.java | 500 ++++++++++++++++++ .../v2/api/IAqualinkDeviceListener.java | 31 ++ .../internal/v2/api/PropertyStorage.java | 29 + .../internal/v2/api/dto/AccountInfo.java | 236 +++++++++ .../internal/v2/api/dto/Credentials.java | 80 +++ .../iaqualink/internal/v2/api/dto/Device.java | 141 +++++ .../iaqualink/internal/v2/api/dto/SignIn.java | 59 +++ .../internal/v2/api/dto/UserPoolOAuth.java | 78 +++ .../internal/v2/api/mapping/ChannelDef.java | 114 ++++ .../internal/v2/api/mapping/Channels.java | 106 ++++ .../internal/v2/api/mapping/DeviceState.java | 41 ++ .../main/resources/OH-INF/thing/iAqualink.xml | 53 ++ .../v2/api/mapping/ChannelMapperTest.java | 472 +++++++++++++++++ 27 files changed, 2477 insertions(+), 32 deletions(-) rename bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/{ => v1}/api/IAqualinkClient.java (97%) rename bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/{ => v1}/api/dto/AccountInfo.java (98%) rename bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/{ => v1}/api/dto/Auxiliary.java (96%) rename bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/{ => v1}/api/dto/Device.java (98%) rename bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/{ => v1}/api/dto/Home.java (98%) rename bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/{ => v1}/api/dto/OneTouch.java (95%) rename bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/{ => v1}/api/dto/SignIn.java (95%) rename bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/{ => v1}/handler/AuxiliaryType.java (97%) rename bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/{ => v1}/handler/HeaterState.java (95%) rename bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/{ => v1}/handler/IAqualinkHandler.java (97%) create mode 100644 bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/IAqualinkV2Handler.java create mode 100644 bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/IAqualinkClient.java create mode 100644 bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/IAqualinkDeviceListener.java create mode 100644 bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/PropertyStorage.java create mode 100644 bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/dto/AccountInfo.java create mode 100644 bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/dto/Credentials.java create mode 100644 bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/dto/Device.java create mode 100644 bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/dto/SignIn.java create mode 100644 bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/dto/UserPoolOAuth.java create mode 100644 bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/mapping/ChannelDef.java create mode 100644 bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/mapping/Channels.java create mode 100644 bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/mapping/DeviceState.java create mode 100644 bundles/org.openhab.binding.iaqualink/src/test/java/org/openhab/binding/iaqualink/internal/v2/api/mapping/ChannelMapperTest.java diff --git a/bundles/org.openhab.binding.iaqualink/pom.xml b/bundles/org.openhab.binding.iaqualink/pom.xml index c8481493ffcda..ea8941e4b1c4d 100644 --- a/bundles/org.openhab.binding.iaqualink/pom.xml +++ b/bundles/org.openhab.binding.iaqualink/pom.xml @@ -1,17 +1,58 @@ - 4.0.0 - org.openhab.addons.bundles org.openhab.addons.reactor.bundles 4.3.0-SNAPSHOT - org.openhab.binding.iaqualink - openHAB Add-ons :: Bundles :: iAquaLink Binding - + + !org.graalvm.nativeimage.hosted;!org.apache.tapestry5.json.*,!org.codehaus.jettison.json.*,!org.json.*,!com.fasterxml.jackson.*,!jakarta.json.* + + + + software.amazon.awssdk.iotdevicesdk + aws-iot-device-sdk + 1.21.0 + compile + + + software.amazon.awssdk.crt + aws-crt + 0.31.3 + + + com.jayway.jsonpath + json-path + 2.9.0 + compile + + + org.slf4j + slf4j-api + + + + + org.ow2.asm + asm + 9.3 + compile + + + net.minidev + accessors-smart + 2.5.0 + compile + + + net.minidev + json-smart + 2.5.0 + compile + + diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/IAqualinkBindingConstants.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/IAqualinkBindingConstants.java index 77fd7c0c8dd1a..f89a5ac06ef34 100644 --- a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/IAqualinkBindingConstants.java +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/IAqualinkBindingConstants.java @@ -21,6 +21,7 @@ * across the whole binding. * * @author Dan Cunningham - Initial contribution + * @author Jonathan Gilbert - Added V2 channel types */ @NonNullByDefault public class IAqualinkBindingConstants { @@ -36,7 +37,10 @@ public class IAqualinkBindingConstants { public static final String CHANNEL_TYPE_ONETOUCH = "onetouch"; + public static final String CHANNEL_TYPE_SCHEDULE_TIME = "schedule-time"; + public static final ThingTypeUID IAQUALINK_DEVICE_THING_TYPE_UID = new ThingTypeUID(BINDING_ID, "controller"); + public static final ThingTypeUID IAQUALINK_DEVICE_V2_THING_TYPE_UID = new ThingTypeUID(BINDING_ID, "controllerV2"); public static final ChannelTypeUID CHANNEL_TYPE_UID_ONETOUCH = new ChannelTypeUID(BINDING_ID, CHANNEL_TYPE_ONETOUCH); @@ -54,4 +58,6 @@ public class IAqualinkBindingConstants { CHANNEL_TYPE_AUX_PENTAIRIB); public static final ChannelTypeUID CHANNEL_TYPE_UID_AUX_HAYWARD = new ChannelTypeUID(BINDING_ID, CHANNEL_TYPE_AUX_HAYWARD); + public static final ChannelTypeUID CHANNEL_TYPE_UID_SCHEDULE_TIME = new ChannelTypeUID(BINDING_ID, + CHANNEL_TYPE_SCHEDULE_TIME); } diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/IAqualinkHandlerFactory.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/IAqualinkHandlerFactory.java index 1fd2a4a3d705c..4c13a7d0ef50a 100644 --- a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/IAqualinkHandlerFactory.java +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/IAqualinkHandlerFactory.java @@ -13,11 +13,13 @@ package org.openhab.binding.iaqualink.internal; import static org.openhab.binding.iaqualink.internal.IAqualinkBindingConstants.IAQUALINK_DEVICE_THING_TYPE_UID; +import static org.openhab.binding.iaqualink.internal.IAqualinkBindingConstants.IAQUALINK_DEVICE_V2_THING_TYPE_UID; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpClient; -import org.openhab.binding.iaqualink.internal.handler.IAqualinkHandler; +import org.openhab.binding.iaqualink.internal.v1.handler.IAqualinkHandler; +import org.openhab.binding.iaqualink.internal.v2.IAqualinkV2Handler; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingTypeUID; @@ -33,6 +35,7 @@ * thing handlers. * * @author Dan Cunningham - Initial contribution + * @author Jonathan Gilbert - Added V2 handler */ @NonNullByDefault @Component(service = ThingHandlerFactory.class, configurationPid = "binding.iaqualink") @@ -47,7 +50,8 @@ public IAqualinkHandlerFactory(@Reference final HttpClientFactory httpClientFact @Override public boolean supportsThingType(ThingTypeUID thingTypeUID) { - return IAQUALINK_DEVICE_THING_TYPE_UID.equals(thingTypeUID); + return IAQUALINK_DEVICE_THING_TYPE_UID.equals(thingTypeUID) + || IAQUALINK_DEVICE_V2_THING_TYPE_UID.equals(thingTypeUID); } @Override @@ -55,6 +59,8 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (IAQUALINK_DEVICE_THING_TYPE_UID.equals(thingTypeUID)) { return new IAqualinkHandler(thing, httpClient); + } else if (IAQUALINK_DEVICE_V2_THING_TYPE_UID.equals(thingTypeUID)) { + return new IAqualinkV2Handler(thing, httpClient); } return null; } diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/IAqualinkClient.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/IAqualinkClient.java similarity index 97% rename from bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/IAqualinkClient.java rename to bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/IAqualinkClient.java index dc370c0eab197..4f8905ace62d8 100644 --- a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/IAqualinkClient.java +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/IAqualinkClient.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.iaqualink.internal.api; +package org.openhab.binding.iaqualink.internal.v1.api; import java.io.IOException; import java.lang.reflect.Type; @@ -31,12 +31,12 @@ import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpMethod; import org.eclipse.jetty.http.HttpStatus; -import org.openhab.binding.iaqualink.internal.api.dto.AccountInfo; -import org.openhab.binding.iaqualink.internal.api.dto.Auxiliary; -import org.openhab.binding.iaqualink.internal.api.dto.Device; -import org.openhab.binding.iaqualink.internal.api.dto.Home; -import org.openhab.binding.iaqualink.internal.api.dto.OneTouch; -import org.openhab.binding.iaqualink.internal.api.dto.SignIn; +import org.openhab.binding.iaqualink.internal.v1.api.dto.AccountInfo; +import org.openhab.binding.iaqualink.internal.v1.api.dto.Auxiliary; +import org.openhab.binding.iaqualink.internal.v1.api.dto.Device; +import org.openhab.binding.iaqualink.internal.v1.api.dto.Home; +import org.openhab.binding.iaqualink.internal.v1.api.dto.OneTouch; +import org.openhab.binding.iaqualink.internal.v1.api.dto.SignIn; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -343,7 +343,7 @@ private UriBuilder baseURI() { /** * * @param - * @param url + * @param uri * @param typeOfT * @return * @throws IOException @@ -355,7 +355,7 @@ private T getAqualinkObject(URI uri, Type typeOfT) throws IOException, NotAu /** * - * @param url + * @param uri * @return * @throws IOException * @throws NotAuthorizedException diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/dto/AccountInfo.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/dto/AccountInfo.java similarity index 98% rename from bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/dto/AccountInfo.java rename to bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/dto/AccountInfo.java index 019413148d41e..148869b66c2d7 100644 --- a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/dto/AccountInfo.java +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/dto/AccountInfo.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.iaqualink.internal.api.dto; +package org.openhab.binding.iaqualink.internal.v1.api.dto; /** * Account Info Object diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/dto/Auxiliary.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/dto/Auxiliary.java similarity index 96% rename from bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/dto/Auxiliary.java rename to bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/dto/Auxiliary.java index c3b38085e938e..f10fcbf81d8ee 100644 --- a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/dto/Auxiliary.java +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/dto/Auxiliary.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.iaqualink.internal.api.dto; +package org.openhab.binding.iaqualink.internal.v1.api.dto; /** * Auxiliary devices. diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/dto/Device.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/dto/Device.java similarity index 98% rename from bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/dto/Device.java rename to bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/dto/Device.java index 520acee58136c..20464974659c3 100644 --- a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/dto/Device.java +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/dto/Device.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.iaqualink.internal.api.dto; +package org.openhab.binding.iaqualink.internal.v1.api.dto; /** * Device refers to an iAqualink Pool Controller. diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/dto/Home.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/dto/Home.java similarity index 98% rename from bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/dto/Home.java rename to bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/dto/Home.java index ea53058e17155..92e67a0916d8a 100644 --- a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/dto/Home.java +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/dto/Home.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.iaqualink.internal.api.dto; +package org.openhab.binding.iaqualink.internal.v1.api.dto; import java.util.Map; diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/dto/OneTouch.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/dto/OneTouch.java similarity index 95% rename from bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/dto/OneTouch.java rename to bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/dto/OneTouch.java index 95acefa9f60a9..2c717d51183b6 100644 --- a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/dto/OneTouch.java +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/dto/OneTouch.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.iaqualink.internal.api.dto; +package org.openhab.binding.iaqualink.internal.v1.api.dto; /** * OneTouch Macros. diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/dto/SignIn.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/dto/SignIn.java similarity index 95% rename from bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/dto/SignIn.java rename to bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/dto/SignIn.java index 5aa2dfae9df31..a965aad479175 100644 --- a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/api/dto/SignIn.java +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/api/dto/SignIn.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.iaqualink.internal.api.dto; +package org.openhab.binding.iaqualink.internal.v1.api.dto; /** * Object used to login to service. diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/handler/AuxiliaryType.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/handler/AuxiliaryType.java similarity index 97% rename from bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/handler/AuxiliaryType.java rename to bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/handler/AuxiliaryType.java index b8241f9963d17..25768aa4aae75 100644 --- a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/handler/AuxiliaryType.java +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/handler/AuxiliaryType.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.iaqualink.internal.handler; +package org.openhab.binding.iaqualink.internal.v1.handler; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/handler/HeaterState.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/handler/HeaterState.java similarity index 95% rename from bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/handler/HeaterState.java rename to bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/handler/HeaterState.java index 034ea750d8593..daa98901e25e5 100644 --- a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/handler/HeaterState.java +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/handler/HeaterState.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.iaqualink.internal.handler; +package org.openhab.binding.iaqualink.internal.v1.handler; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/handler/IAqualinkHandler.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/handler/IAqualinkHandler.java similarity index 97% rename from bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/handler/IAqualinkHandler.java rename to bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/handler/IAqualinkHandler.java index 6a09a35ec83f9..ee4ca7fda53b4 100644 --- a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/handler/IAqualinkHandler.java +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v1/handler/IAqualinkHandler.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.iaqualink.internal.handler; +package org.openhab.binding.iaqualink.internal.v1.handler; import static org.openhab.core.library.unit.ImperialUnits.FAHRENHEIT; import static org.openhab.core.library.unit.SIUnits.CELSIUS; @@ -36,14 +36,14 @@ import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpClient; import org.openhab.binding.iaqualink.internal.IAqualinkBindingConstants; -import org.openhab.binding.iaqualink.internal.api.IAqualinkClient; -import org.openhab.binding.iaqualink.internal.api.IAqualinkClient.NotAuthorizedException; -import org.openhab.binding.iaqualink.internal.api.dto.AccountInfo; -import org.openhab.binding.iaqualink.internal.api.dto.Auxiliary; -import org.openhab.binding.iaqualink.internal.api.dto.Device; -import org.openhab.binding.iaqualink.internal.api.dto.Home; -import org.openhab.binding.iaqualink.internal.api.dto.OneTouch; import org.openhab.binding.iaqualink.internal.config.IAqualinkConfiguration; +import org.openhab.binding.iaqualink.internal.v1.api.IAqualinkClient; +import org.openhab.binding.iaqualink.internal.v1.api.IAqualinkClient.NotAuthorizedException; +import org.openhab.binding.iaqualink.internal.v1.api.dto.AccountInfo; +import org.openhab.binding.iaqualink.internal.v1.api.dto.Auxiliary; +import org.openhab.binding.iaqualink.internal.v1.api.dto.Device; +import org.openhab.binding.iaqualink.internal.v1.api.dto.Home; +import org.openhab.binding.iaqualink.internal.v1.api.dto.OneTouch; import org.openhab.core.library.types.DecimalType; import org.openhab.core.library.types.OnOffType; import org.openhab.core.library.types.PercentType; diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/IAqualinkV2Handler.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/IAqualinkV2Handler.java new file mode 100644 index 0000000000000..7ecf357ffa58b --- /dev/null +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/IAqualinkV2Handler.java @@ -0,0 +1,452 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.iaqualink.internal.v2; + +import static org.openhab.core.library.unit.SIUnits.CELSIUS; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import javax.measure.Unit; +import javax.measure.quantity.Temperature; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.iaqualink.internal.IAqualinkBindingConstants; +import org.openhab.binding.iaqualink.internal.config.IAqualinkConfiguration; +import org.openhab.binding.iaqualink.internal.v2.api.IAqualinkClient; +import org.openhab.binding.iaqualink.internal.v2.api.IAqualinkClient.NotAuthorizedException; +import org.openhab.binding.iaqualink.internal.v2.api.IAqualinkDeviceListener; +import org.openhab.binding.iaqualink.internal.v2.api.PropertyStorage; +import org.openhab.binding.iaqualink.internal.v2.api.dto.Device; +import org.openhab.binding.iaqualink.internal.v2.api.mapping.ChannelDef; +import org.openhab.binding.iaqualink.internal.v2.api.mapping.Channels; +import org.openhab.binding.iaqualink.internal.v2.api.mapping.DeviceState; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.unit.Units; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.thing.binding.builder.ChannelBuilder; +import org.openhab.core.thing.binding.builder.ThingBuilder; +import org.openhab.core.thing.type.ChannelTypeUID; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * iAquaLink Control Binding + * + * iAquaLink controllers allow remote access to Jandy/Zodiac pool systems. This + * binding allows openHAB to both monitor and control a pool system through + * these controllers. + *

+ * The {@link IAqualinkV2Handler} is responsible for handling commands, which + * are sent to one of the channels. + * + * @author Jonathan Gilbert - Initial contribution + */ +@NonNullByDefault +public class IAqualinkV2Handler extends BaseThingHandler implements IAqualinkDeviceListener, PropertyStorage { + + private final Logger logger = LoggerFactory.getLogger(IAqualinkV2Handler.class); + + /** + * Default iAqulink key used by existing clients in the marketplace + */ + private static final String DEFAULT_API_KEY = "EOOEMOW4YR6QNB07"; + + /** + * Local cache of iAqualink states + */ + private final Map stateMap = Collections.synchronizedMap(new HashMap<>()); + + /** + * When we first connect we will dynamically create channels based on what the controller is configured for + */ + private @Nullable Collection channelDefs = null; + + private @Nullable Device device; + + /** + * The client interface to the iAqualink Service + */ + private @Nullable IAqualinkClient client; + + private final HttpClient httpClient; + + /** + * Temperature unit, will be set based on user setting + */ + private final Unit temperatureUnit = CELSIUS; + + /** + * Constructs a new {@link IAqualinkV2Handler} + */ + public IAqualinkV2Handler(Thing thing, HttpClient httpClient) { + super(thing); + this.httpClient = httpClient; + } + + @Override + public void initialize() { + // don't hold up initialize + scheduler.schedule(this::configure, 0, TimeUnit.SECONDS); + } + + @Override + public void dispose() { + logger.debug("Handler disposed."); + + if (client != null) { + try { + disconnect(); + } catch (ExecutionException | InterruptedException e) { + // swallow exception to allow handler to be disposed + logger.error("Failed to disconnect from iAqualink", e); + } + } + } + + @Override + public @Nullable String getProperty(String name) { + return editProperties().get(name); + } + + @Override + public void setProperty(String name, @Nullable String value) { + Map props = editProperties(); + if (value == null) { + props.remove(name); + } else { + props.put(name, value); + } + updateProperties(props); + } + + @Override + public void channelLinked(ChannelUID channelUID) { + // clear our cached value so the new channel gets updated on the next poll + stateMap.remove(channelUID.getAsString()); + } + + /** + * Configures this thing + */ + private void configure() { + + try { + disconnect(); + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + + channelDefs = null; + + IAqualinkConfiguration configuration = getConfig().as(IAqualinkConfiguration.class); + String confSerialId = configuration.serialId; + String confApiKey = configuration.apiKey.isBlank() ? DEFAULT_API_KEY : configuration.apiKey; + + try { + IAqualinkClient client = new IAqualinkClient(httpClient, configuration.userName, configuration.password, + confApiKey, scheduler, this); + + Device[] devices = client.getDevices(); + + if (devices.length == 0) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No registered devices found"); + return; + } + + Device newDevice; + + if (!confSerialId.isBlank()) { + String serialNumber = confSerialId.replaceAll("[^a-zA-Z0-9]", "").toUpperCase(); + + newDevice = Arrays.stream(devices).filter(d -> d.getSerialNumber().equals(serialNumber)).findFirst() + .orElseGet(() -> devices[0]); + + if (Arrays.stream(devices).noneMatch(d -> d.getSerialNumber().equals(serialNumber))) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "No Device for given serialId found"); + return; + } + } else { + newDevice = devices[0]; + } + + logger.debug("Using serial number {}", newDevice.getSerialNumber()); + + device = newDevice; + this.client = client; + + client.connect(); + startListening(newDevice); + } catch (IOException e) { + logger.debug("Could not connect to service {}", e.getMessage()); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + } catch (NotAuthorizedException e) { + logger.debug("Credentials not valid"); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Credentials not valid"); + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + /** + * Starts/Restarts listening to message queue. + */ + private void startListening(Device device) + throws UnsupportedEncodingException, ExecutionException, InterruptedException { + IAqualinkClient client = this.client; + + if (client == null) { + throw new IllegalStateException("Client not initialized"); + } + + client.subscribe(device, this) + .thenAccept((ignore) -> logger.info("Listening for events for {}", device.getSerialNumber())).get(); + + client.doGetDevice(device); + } + + /** + * Stops/clears this thing's polling future + */ + private void disconnect() throws ExecutionException, InterruptedException { + IAqualinkClient client = this.client; + + if (client != null) { + client.disconnect(); + } + } + + @Override + public void onGetAccepted(String deviceId, String msg) { + DeviceState newState = DeviceState.parse(msg); + + Collection channelDefs = this.channelDefs; + + if (channelDefs == null) { + channelDefs = Channels.appliesToState(Channels.all(), newState); + this.channelDefs = channelDefs; + updateChannels(channelDefs); + } + + for (ChannelDef channel : channelDefs) { + updatedState(channel, channel.value(newState)); + } + + if (getThing().getStatus() != ThingStatus.ONLINE) { + updateStatus(ThingStatus.ONLINE); + } + } + + @Override + public void onUpdateAccepted(String deviceId, String msg) { + DeviceState partialState = DeviceState.parse(msg); + + Collection channelDefs = this.channelDefs; + + if (channelDefs == null) { + logger.warn("Update ignored; Controller channels not yet initialized"); + return; + } + + for (ChannelDef channel : channelDefs) { + Object newValue = channel.value(partialState); + // only update present values + if (newValue != null) { + updatedState(channel, newValue); + } + } + + if (getThing().getStatus() != ThingStatus.ONLINE) { + updateStatus(ThingStatus.ONLINE); + } + } + + @Override + public void onUpdateRejected(String deviceId, String msg) { + logger.error("Update rejected for device {}: {}", deviceId, msg); + } + + @Override + public void onDisconnected(String deviceId) { + logger.error("Disconnected from device {}!", deviceId); + updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.COMMUNICATION_ERROR); + } + + /** + * Update a channel value only if the value of the channel has changed since our last poll. + */ + private void updatedState(ChannelDef channelDef, @Nullable Object newState) { + logger.trace("updatedState {} : {}", channelDef.id(), newState); + Channel channel = getThing().getChannel(channelDef.id()); + if (channel != null) { + State state = toState(channel.getAcceptedItemType(), newState != null ? newState.toString() : null); + State oldState = stateMap.put(channel.getUID().getAsString(), state); + if (!state.equals(oldState)) { + logger.trace("updating channel {} with value {} (old value {})", channel.getUID(), state, oldState); + updateState(channel.getUID(), state); + } + } + } + + /** + * Converts a {@link String} value to a {@link State} for a given + * {@link String} accepted type + */ + private State toState(@Nullable String type, @Nullable String value) { + try { + if (value == null || value.isBlank()) { + return UnDefType.UNDEF; + } else { + if (type == null) { + return StringType.valueOf(value); + } else { + return switch (type) { + case "Number:Temperature" -> new QuantityType<>(Float.parseFloat(value), temperatureUnit); + case "Number:Time" -> new QuantityType<>( + Duration.parse("PT" + value.replace(':', 'H') + "M").toMinutes(), Units.MINUTE); + case "Number" -> new DecimalType(value); + case "Dimmer" -> new PercentType(value); + case "Switch" -> OnOffType.from(Integer.parseInt(value) > 0); + default -> StringType.valueOf(value); + }; + } + } + } catch (IllegalArgumentException e) { + return UnDefType.UNDEF; + } + } + + /** + * Creates channels based on what is supported by the controller. + */ + private void updateChannels(Collection channelDefs) { + List channels = new ArrayList<>(getThing().getChannels()); + + for (ChannelDef channelDef : channelDefs) { + logger.debug("Add channel Id: {} Type: {}", channelDef.id(), channelDef.itemType()); + ChannelTypeUID channelTypeUID = new ChannelTypeUID(IAqualinkBindingConstants.BINDING_ID, + channelDef.typeId()); + ChannelUID channelUID = new ChannelUID(getThing().getUID(), channelDef.id()); + Channel channel = ChannelBuilder.create(channelUID, channelDef.itemType()).withType(channelTypeUID) + .withLabel(channelDef.label()).build(); + + // if there is no entry, add it + if (channels.stream().noneMatch(c -> c.getUID().equals(channelUID))) { + logger.debug("Adding channel {}", channelDef.id()); + channels.add(channel); + } else if (channels + .removeIf(c -> c.getUID().equals(channelUID) && !channelTypeUID.equals(c.getChannelTypeUID()))) { + // this channel uid exists, but has a different type so remove and add our new one + logger.debug("Replacing channel {}", channelDef.id()); + channels.add(channel); + } + } + + ThingBuilder thingBuilder = editThing(); + thingBuilder.withChannels(channels); + updateThing(thingBuilder.build()); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + logger.debug("handleCommand channel: {} command: {}", channelUID, command); + + if (getThing().getStatus() != ThingStatus.ONLINE) { + logger.warn("Controller is not ONLINE and is not responding to commands"); + return; + } + + Collection channelDefs = this.channelDefs; + + if (channelDefs == null) { + logger.warn("Controller channels not yet initialized"); + return; + } + + String channelName = channelUID.getIdWithoutGroup(); + // remove the current value to ensure we send an update + stateMap.remove(channelUID.getAsString()); + try { + if (command instanceof RefreshType) { + logger.debug("Channel {} value has been cleared", channelName); + } else { + Optional channelDef = channelDefs.stream() + .filter(channel -> channel.id().equals(channelName)).findFirst(); + + if (channelDef.isEmpty()) { + logger.warn("Channel {} is not supported", channelName); + } else { + Object newStateAsJson = commandToJsonObject(command); + if (newStateAsJson != null) { + DeviceState newState = channelDef.get().updateJson(newStateAsJson); + IAqualinkClient client = this.client; + Device device = this.device; + if (client != null && device != null) { + client.publishUpdate(device.getSerialNumber(), newState.jsonString()).get(); + } else { + logger.warn("Update not published as client or device disposing."); + } + } else { + logger.warn("Command {} from channel {} does not map to a json object", command, + channelDef.get().id()); + } + } + } + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + private @Nullable Object commandToJsonObject(Command command) { + if (command instanceof OnOffType onOffType) { + if (onOffType == OnOffType.ON) { + return 1; + } else if (onOffType == OnOffType.OFF) { + return 0; + } else { + return null; + } + } else if (command instanceof DecimalType) { + return ((DecimalType) command).doubleValue(); + } else { + throw new IllegalArgumentException("Unsupported command type " + command.getClass().getSimpleName()); + } + } +} diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/IAqualinkClient.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/IAqualinkClient.java new file mode 100644 index 0000000000000..d8df164d007be --- /dev/null +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/IAqualinkClient.java @@ -0,0 +1,500 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.iaqualink.internal.v2.api; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import javax.ws.rs.core.UriBuilder; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.client.util.StringContentProvider; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpStatus; +import org.openhab.binding.iaqualink.internal.v2.api.dto.AccountInfo; +import org.openhab.binding.iaqualink.internal.v2.api.dto.Device; +import org.openhab.binding.iaqualink.internal.v2.api.dto.SignIn; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.FieldNamingPolicy; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; + +import software.amazon.awssdk.crt.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.crt.mqtt5.Mqtt5Client; +import software.amazon.awssdk.crt.mqtt5.Mqtt5ClientOptions; +import software.amazon.awssdk.crt.mqtt5.OnAttemptingConnectReturn; +import software.amazon.awssdk.crt.mqtt5.OnConnectionFailureReturn; +import software.amazon.awssdk.crt.mqtt5.OnConnectionSuccessReturn; +import software.amazon.awssdk.crt.mqtt5.OnDisconnectionReturn; +import software.amazon.awssdk.crt.mqtt5.OnStoppedReturn; +import software.amazon.awssdk.crt.mqtt5.PublishResult; +import software.amazon.awssdk.crt.mqtt5.PublishReturn; +import software.amazon.awssdk.crt.mqtt5.QOS; +import software.amazon.awssdk.crt.mqtt5.packets.PublishPacket; +import software.amazon.awssdk.crt.mqtt5.packets.SubscribePacket; +import software.amazon.awssdk.iot.AwsIotMqtt5ClientBuilder; + +/** + * IAqualink HTTP Client + * + * The {@link org.openhab.binding.iaqualink.internal.v1.api.IAqualinkClient} provides basic HTTP commands to control and + * monitor an iAquaLink + * based system. + * + * GSON is used to provide custom deserialization on the JSON results. These results + * unfortunately are not returned as normalized JSON objects and require complex deserialization + * handlers. + * + * @author Jonathan Gilbert - Initial contribution + */ +@NonNullByDefault +public class IAqualinkClient implements Mqtt5ClientOptions.LifecycleEvents, Mqtt5ClientOptions.PublishEvents { + private final Logger logger = LoggerFactory.getLogger(IAqualinkClient.class); + + private static final String HEADER_AGENT = "iAqualink/98 CFNetwork/978.0.7 Darwin/18.6.0"; + private static final String HEADER_ACCEPT = "*/*"; + private static final String HEADER_ACCEPT_LANGUAGE = "en-us"; + private static final String HEADER_ACCEPT_ENCODING = "br, gzip, deflate"; + + /** + * Default 'Secret' iAqualink key used by existing clients in the marketplace + */ + private static final String DEFAULT_SECRET_API_KEY = "cj7iYKjiKxOqiLcN65PffA"; + + private static final String AUTH_URL = "https://prod.zodiac-io.com/users/v1/login"; + private static final String DEVICES_URL = "https://prm.iaqualink.net/v2/devices.json"; + private static final String AWS_URL = "a1zi08qpbrtjyq-ats.iot.us-east-1.amazonaws.com"; + private static final String AWS_REGION = "us-east-1"; + + private static final String TOPIC_THINGS_SHADOW = "$aws/things/%s/shadow"; + + private static final String TOPIC_SUFFIX_GET = "/get"; + private static final String TOPIC_SUFFIX_UPDATE = "/update"; + + private static final String TOPIC_SUFFIX_ACCEPTED = "/accepted"; + private static final String TOPIC_SUFFIX_REJECTED = "/rejected"; + + /** + * Connection to AWS IOT + */ + @Nullable + private Mqtt5Client mqttClient; + + private final Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .create(); + + private final ScheduledExecutorService scheduler; + private final HttpClient httpClient; + @Nullable + protected AccountInfo accountInfo = null; + + boolean connectCredsFailed = false; + + private final Map listeners = new HashMap<>(); + + private final String apiKey; + private final String username; + private final String password; + private final PropertyStorage propertyStorage; + + public CompletableFuture publishUpdate(String deviceId, String msg) { + return getMqttClient().publish(mqttMessage(String.format(TOPIC_THINGS_SHADOW + TOPIC_SUFFIX_UPDATE, deviceId), + msg.getBytes(StandardCharsets.UTF_8))); + } + + @SuppressWarnings("serial") + public static class NotAuthorizedException extends Exception { + public NotAuthorizedException(String message) { + super(message); + } + } + + public IAqualinkClient(HttpClient httpClient, String username, String password, String apiKey, + ScheduledExecutorService scheduler, PropertyStorage propertyStorage) { + this.username = username; + this.password = password; + this.httpClient = httpClient; + this.apiKey = apiKey; + this.scheduler = scheduler; + this.propertyStorage = propertyStorage; + } + + /** + * Initial login to service + */ + private AccountInfo login() throws IOException, NotAuthorizedException { + + String signIn = gson.toJson(new SignIn(apiKey, username, password)); + try { + ContentResponse response = httpClient.newRequest(AUTH_URL).method(HttpMethod.POST) + .content(new StringContentProvider(signIn), "application/json").send(); + if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) { + throw new NotAuthorizedException(response.getReason()); + } + if (response.getStatus() != HttpStatus.OK_200) { + throw new IOException(response.getReason()); + } + + return Objects.requireNonNull(gson.fromJson(response.getContentAsString(), AccountInfo.class)); + } catch (InterruptedException | TimeoutException | ExecutionException e) { + throw new IOException(e); + } + } + + private AccountInfo getAccountInfo() throws IOException, NotAuthorizedException { + AccountInfo loaded = loadAccountInfo(false); + if (loaded.getCredentials().getExpiration().before(new Date())) { + return refreshAccountInfo(); + } else { + return loaded; + } + } + + private AccountInfo refreshAccountInfo() throws IOException, NotAuthorizedException { + logger.info("Refreshing authentication tokens"); + return loadAccountInfo(true); + } + + private AccountInfo loadAccountInfo(boolean relogin) throws IOException, NotAuthorizedException { + + AccountInfo accountInfo = null; + if (!relogin) { + // first check cache + accountInfo = this.accountInfo; + if (accountInfo != null) { + return accountInfo; + } + + // then check storage for tokens + String credString = propertyStorage.getProperty("credentials"); + + if (credString != null) { + accountInfo = gson.fromJson(credString, AccountInfo.class); + } + } + + if (accountInfo == null) { // otherwise login + accountInfo = login(); + propertyStorage.setProperty("credentials", gson.toJson(accountInfo)); + } + + this.accountInfo = accountInfo; + + return accountInfo; + } + + /** + * List all devices (pools) registered to an account + */ + public Device[] getDevices() throws IOException, NotAuthorizedException { + AccountInfo accountInfo = getAccountInfo(); + + int timestamp = Math.round(((float) new Date().getTime()) / 1000.0f); + + // + URI uri = UriBuilder.fromUri(DEVICES_URL). // + queryParam("user_id", accountInfo.getId()).queryParam("timestamp", String.valueOf(timestamp)).build(); + return Objects.requireNonNull(gson.fromJson(getRequest(uri, accountInfo.getId().toString() + "," + timestamp), + (Type) Device[].class)); + } + + @Override + public void onAttemptingConnect(@Nullable Mqtt5Client client, + @Nullable OnAttemptingConnectReturn onAttemptingConnectReturn) { + logger.trace("MQTT connecting..."); + } + + @Override + public void onConnectionSuccess(@Nullable Mqtt5Client client, + @Nullable OnConnectionSuccessReturn onConnectionSuccessReturn) { + logger.info("MQTT connected"); + connectCredsFailed = false; + } + + @Override + public void onConnectionFailure(@Nullable Mqtt5Client client, + @Nullable OnConnectionFailureReturn onConnectionFailureReturn) { + logger.error("MQTT connect failed: {}", onConnectionFailureReturn == null ? "unknown" + : software.amazon.awssdk.crt.CRT.awsErrorString(onConnectionFailureReturn.getErrorCode())); + + if (onConnectionFailureReturn.getErrorCode() == 2065) { + // possibly due to expired credentials, try to re-login and reconnect + if (!connectCredsFailed) { + scheduler.schedule(() -> { + try { + disconnect(); + refreshAccountInfo(); + connectCredsFailed = true; + connect(); + } catch (IOException | NotAuthorizedException | ExecutionException | InterruptedException e) { + logger.error("Failed to reconnect!", e); + } + }, 0, TimeUnit.SECONDS); + } else { + logger.error("Failed to reconnect, likely due to invalid credentials; disconnecting"); + disconnect(); + } + } else { + logger.info("Attempting reconnect"); + } + } + + @Override + public void onDisconnection(@Nullable Mqtt5Client client, @Nullable OnDisconnectionReturn onDisconnectionReturn) { + logger.info("MQTT disconnected"); + } + + @Override + public void onStopped(@Nullable Mqtt5Client client, @Nullable OnStoppedReturn onStoppedReturn) { + logger.info("MQTT stopped"); + } + + @Override + public void onMessageReceived(@Nullable final Mqtt5Client client, @Nullable final PublishReturn publishReturn) { + scheduler.schedule(() -> { + + if (publishReturn == null) { + logger.error("MQTT message received null publish return"); + return; + } + + String topic = publishReturn.getPublishPacket().getTopic(); + + String payload = new String(publishReturn.getPublishPacket().getPayload(), StandardCharsets.UTF_8); + + logger.debug("MQTT message received on {}: {}", topic, + Arrays.toString(publishReturn.getPublishPacket().getPayload())); + + String[] parts = topic.split("/"); + if (parts.length != 6) { + logger.error("MQTT message received on unknown topic [1]: {}", topic); + return; + } + + String[] partsWithPlaceholder = Arrays.copyOf(parts, parts.length); + partsWithPlaceholder[2] = "%s"; + String topicWithPlaceholder = String.join("/", partsWithPlaceholder); + + if (!topicWithPlaceholder.startsWith(TOPIC_THINGS_SHADOW)) { + logger.error("MQTT message received on unknown topic [2]: {}", topic); + return; + } + + String deviceId = parts[2]; + IAqualinkDeviceListener listener = listeners.get(deviceId); + + if (listener == null) { + logger.warn("Listener not found for device: {}", deviceId); + } + + if (parts[4].equals(TOPIC_SUFFIX_GET.substring(1))) { + logger.debug("Received get accepted message: {}", payload); + if (listener != null) { // keep compiler happy + listener.onGetAccepted(deviceId, payload); + } + + } else if (parts[4].equals(TOPIC_SUFFIX_UPDATE.substring(1))) { + if (parts[5].equals(TOPIC_SUFFIX_ACCEPTED.substring(1))) { + logger.debug("Received update accepted message: {}", payload); + if (listener != null) { // keep compiler happy + listener.onUpdateAccepted(deviceId, payload); + } + + } else if (parts[5].equals(TOPIC_SUFFIX_REJECTED.substring(1))) { + logger.debug("Received update rejected message: {}", payload); + if (listener != null) { // keep compiler happy + listener.onUpdateRejected(deviceId, payload); + } + + } else { + logger.error("MQTT message received on unknown topic [3]: {}", topic); + + } + } else { + logger.error("MQTT message received on unknown topic [4]: {}", topic); + return; + } + }, 0, TimeUnit.SECONDS); + } + + public void connect() throws IOException, ExecutionException, InterruptedException, NotAuthorizedException { + // Create the MQTT client + disconnect(); + + AccountInfo accountInfo = getAccountInfo(); + + StaticCredentialsProvider credentialsProvider = new StaticCredentialsProvider.StaticCredentialsProviderBuilder() + .withSessionToken(accountInfo.getCredentials().getSessionToken().getBytes()) + .withAccessKeyId(accountInfo.getCredentials().getAccessKeyId().getBytes()) + .withSecretAccessKey(accountInfo.getCredentials().getSecretKey().getBytes()).build(); + + AwsIotMqtt5ClientBuilder.WebsocketSigv4Config config = new AwsIotMqtt5ClientBuilder.WebsocketSigv4Config(); + config.credentialsProvider = credentialsProvider; + config.region = AWS_REGION; + + AwsIotMqtt5ClientBuilder builder = AwsIotMqtt5ClientBuilder + .newWebsocketMqttBuilderWithSigv4Auth(AWS_URL, config).withLifeCycleEvents(this) + .withPublishEvents(this); + + Mqtt5Client client = builder.build(); + builder.close(); + // Connect the MQTT client + client.start(); + mqttClient = client; + } + + public void disconnect() { + Mqtt5Client mqtt5Client = this.mqttClient; + if (mqtt5Client != null) { + mqtt5Client.stop(); + mqttClient = null; + } + } + + private Mqtt5Client getMqttClient() { + Mqtt5Client rv = this.mqttClient; + if (rv == null) { + throw new IllegalStateException("MQTT client not initialized"); + } + return rv; + } + + public CompletableFuture subscribe(Device device, IAqualinkDeviceListener listener) { + + listeners.put(device.getSerialNumber(), listener); + + SubscribePacket subscribePacket = new SubscribePacket.SubscribePacketBuilder() + .withSubscription(String.format(TOPIC_THINGS_SHADOW + TOPIC_SUFFIX_GET + TOPIC_SUFFIX_ACCEPTED, + device.getSerialNumber()), QOS.AT_LEAST_ONCE) + .withSubscription(String.format(TOPIC_THINGS_SHADOW + TOPIC_SUFFIX_UPDATE + TOPIC_SUFFIX_ACCEPTED, + device.getSerialNumber()), QOS.AT_LEAST_ONCE) + .withSubscription(String.format(TOPIC_THINGS_SHADOW + TOPIC_SUFFIX_UPDATE + TOPIC_SUFFIX_REJECTED, + device.getSerialNumber()), QOS.AT_LEAST_ONCE) + .build(); + + return getMqttClient().subscribe(subscribePacket).thenAccept((v) -> { + v.getReasonCodes().stream().filter(r -> r.getValue() > 100).forEach(r -> { + logger.error("Failed to subscribe to topic: {}", v.getReasonString()); + throw new RuntimeException("Failed to subscribe to topic: " + v.getReasonString()); + }); + + logger.debug("Subscribed to device topics ({})", v.getReasonString()); + }); + } + + public void doGetDevice(Device d) { + getMqttClient() + .publish(mqttMessage(String.format(TOPIC_THINGS_SHADOW + TOPIC_SUFFIX_GET, d.getSerialNumber()), null)) + .handleAsync((v, e) -> { + if (e != null) { + logger.error("Failed to send get request to device: {}", e.getMessage()); + throw new RuntimeException("Failed to send get request to device: " + e.getMessage()); + } else { + logger.debug("Sent get request to device: {}", v); + } + + return null; + }); + } + + private PublishPacket mqttMessage(String topic, byte @Nullable [] payload) { + PublishPacket.PublishPacketBuilder builder = new PublishPacket.PublishPacketBuilder(); + if (payload != null && payload.length > 0) { + builder.withPayload(payload); + } + builder.withQOS(QOS.AT_LEAST_ONCE); + builder.withPayloadFormat(PublishPacket.PayloadFormatIndicator.BYTES); + builder.withRetain(false); + builder.withContentType("application/json"); + builder.withTopic(topic); + return builder.build(); + } + + private String getRequest(URI uri, @Nullable String sigSource) throws IOException, NotAuthorizedException { + try { + AccountInfo accountInfo = this.accountInfo; + + // we don't set the signature if we don't have an account info (yet) + if (accountInfo != null && sigSource != null) { + String signature = generateSignatureUsingSecretKey(sigSource, DEFAULT_SECRET_API_KEY); + + uri = UriBuilder.fromUri(uri).queryParam("signature", signature).build(); + } + logger.trace("Trying {}", uri); + + Request request = httpClient.newRequest(uri).method(HttpMethod.GET) // + .agent(HEADER_AGENT) // + .header(HttpHeader.ACCEPT_LANGUAGE, HEADER_ACCEPT_LANGUAGE) // + .header(HttpHeader.ACCEPT_ENCODING, HEADER_ACCEPT_ENCODING) // + .header(HttpHeader.ACCEPT, HEADER_ACCEPT); // + + if (accountInfo != null) { + request = request.header("api_key", apiKey).header(HttpHeader.AUTHORIZATION, + accountInfo.getUserPoolOAuth().getIdToken()); + } + + ContentResponse response = request.send(); + logger.trace("Response {}", response); + + if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) { + throw new NotAuthorizedException(response.getReason()); + } + if (response.getStatus() != HttpStatus.OK_200) { + throw new IOException(response.getReason()); + } + return response.getContentAsString(); + } catch (InterruptedException | TimeoutException | ExecutionException | JsonParseException e) { + throw new IOException(e); + } + } + + private static String generateSignatureUsingSecretKey(String data, String secretKey) { + try { + Mac instance = Mac.getInstance("HmacSHA1"); + instance.init(new SecretKeySpec(secretKey.getBytes(), "HmacSHA1")); + return toHexString(instance.doFinal(data.getBytes())); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static String toHexString(byte[] a) { + StringBuilder sb = new StringBuilder(a.length * 2); + for (byte b : a) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } +} diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/IAqualinkDeviceListener.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/IAqualinkDeviceListener.java new file mode 100644 index 0000000000000..a175cbc1415e2 --- /dev/null +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/IAqualinkDeviceListener.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.iaqualink.internal.v2.api; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Listener for IAqualink device messages + * + * @author Jonathan Gilbert - Initial contribution + */ +@NonNullByDefault +public interface IAqualinkDeviceListener { + void onGetAccepted(String deviceId, String msg); + + void onUpdateAccepted(String deviceId, String msg); + + void onUpdateRejected(String deviceId, String msg); + + void onDisconnected(String deviceId); +} diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/PropertyStorage.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/PropertyStorage.java new file mode 100644 index 0000000000000..9eb35219bdd71 --- /dev/null +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/PropertyStorage.java @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.iaqualink.internal.v2.api; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Used to store and retrieve properties of a device. + * + * @author Jonathan Gilbert - Initial contribution + */ +@NonNullByDefault +public interface PropertyStorage { + @Nullable + String getProperty(String name); + + void setProperty(String name, @Nullable String value); +} diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/dto/AccountInfo.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/dto/AccountInfo.java new file mode 100644 index 0000000000000..c905039305864 --- /dev/null +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/dto/AccountInfo.java @@ -0,0 +1,236 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.iaqualink.internal.v2.api.dto; + +import com.google.gson.annotations.SerializedName; + +/** + * Account Info Object + * + * @author Jonathan Gilbert - Initial contribution + * + */ +public class AccountInfo { + + private Integer id; + + private String email; + + private String createdAt; + + private String updatedAt; + + private Object timeZone; + + private String firstName; + + private String lastName; + + private String address1; + + private String address2; + + private String city; + + private String state; + + private String postalCode; + + private String country; + + private String phone; + + private Boolean optIn1; + + private Boolean optIn2; + + private String authenticationToken; + + private String role; + + private String sessionId; + + @SerializedName("userPoolOAuth") + public UserPoolOAuth userPoolOAuth; + + @SerializedName("credentials") + Credentials credentials; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } + + public String getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(String updatedAt) { + this.updatedAt = updatedAt; + } + + public Object getTimeZone() { + return timeZone; + } + + public void setTimeZone(Object timeZone) { + this.timeZone = timeZone; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getAddress1() { + return address1; + } + + public void setAddress1(String address1) { + this.address1 = address1; + } + + public String getAddress2() { + return address2; + } + + public void setAddress2(String address2) { + this.address2 = address2; + } + + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getPostalCode() { + return postalCode; + } + + public void setPostalCode(String postalCode) { + this.postalCode = postalCode; + } + + public String getCountry() { + return country; + } + + public void setCountry(String country) { + this.country = country; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public Boolean getOptIn1() { + return optIn1; + } + + public void setOptIn1(Boolean optIn1) { + this.optIn1 = optIn1; + } + + public Boolean getOptIn2() { + return optIn2; + } + + public void setOptIn2(Boolean optIn2) { + this.optIn2 = optIn2; + } + + public String getAuthenticationToken() { + return authenticationToken; + } + + public void setAuthenticationToken(String authenticationToken) { + this.authenticationToken = authenticationToken; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public void setUserPoolOAuth(UserPoolOAuth userPoolOAuth) { + this.userPoolOAuth = userPoolOAuth; + } + + public UserPoolOAuth getUserPoolOAuth() { + return userPoolOAuth; + } + + public void setCredentials(Credentials credentials) { + this.credentials = credentials; + } + + public Credentials getCredentials() { + return credentials; + } +} diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/dto/Credentials.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/dto/Credentials.java new file mode 100644 index 0000000000000..166a67f695635 --- /dev/null +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/dto/Credentials.java @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.iaqualink.internal.v2.api.dto; + +import java.util.Date; + +import com.google.gson.annotations.SerializedName; + +/** + * AWS access credentials. + * + * @author Jonathan Gilbert - Initial contribution + */ +public class Credentials { + + @SerializedName("AccessKeyId") + String AccessKeyId; + + @SerializedName("Expiration") + Date Expiration; + + @SerializedName("IdentityId") + String IdentityId; + + @SerializedName("SecretKey") + String SecretKey; + + @SerializedName("SessionToken") + String SessionToken; + + public void setAccessKeyId(String AccessKeyId) { + this.AccessKeyId = AccessKeyId; + } + + public String getAccessKeyId() { + return AccessKeyId; + } + + public void setExpiration(Date Expiration) { + this.Expiration = Expiration; + } + + public Date getExpiration() { + return Expiration; + } + + public void setIdentityId(String IdentityId) { + this.IdentityId = IdentityId; + } + + public String getIdentityId() { + return IdentityId; + } + + public void setSecretKey(String SecretKey) { + this.SecretKey = SecretKey; + } + + public String getSecretKey() { + return SecretKey; + } + + public void setSessionToken(String SessionToken) { + this.SessionToken = SessionToken; + } + + public String getSessionToken() { + return SessionToken; + } +} diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/dto/Device.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/dto/Device.java new file mode 100644 index 0000000000000..266f5387b38ef --- /dev/null +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/dto/Device.java @@ -0,0 +1,141 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.iaqualink.internal.v2.api.dto; + +/** + * Device refers to an iAqualink Pool Controller. + * + * @author Jonathan Gilbert - Initial contribution + */ +public class Device { + + private Integer id; + + private String serialNumber; + + private String createdAt; + + private String updatedAt; + + private String name; + + private String deviceType; + + private Object ownerId; + + private Boolean updating; + + private Object firmwareVersion; + + private Object targetFirmwareVersion; + + private Object updateFirmwareStartAt; + + private Object lastActivityAt; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getSerialNumber() { + return serialNumber; + } + + public void setSerialNumber(String serialNumber) { + this.serialNumber = serialNumber; + } + + public String getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } + + public String getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(String updatedAt) { + this.updatedAt = updatedAt; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDeviceType() { + return deviceType; + } + + public void setDeviceType(String deviceType) { + this.deviceType = deviceType; + } + + public Object getOwnerId() { + return ownerId; + } + + public void setOwnerId(Object ownerId) { + this.ownerId = ownerId; + } + + public Boolean getUpdating() { + return updating; + } + + public void setUpdating(Boolean updating) { + this.updating = updating; + } + + public Object getFirmwareVersion() { + return firmwareVersion; + } + + public void setFirmwareVersion(Object firmwareVersion) { + this.firmwareVersion = firmwareVersion; + } + + public Object getTargetFirmwareVersion() { + return targetFirmwareVersion; + } + + public void setTargetFirmwareVersion(Object targetFirmwareVersion) { + this.targetFirmwareVersion = targetFirmwareVersion; + } + + public Object getUpdateFirmwareStartAt() { + return updateFirmwareStartAt; + } + + public void setUpdateFirmwareStartAt(Object updateFirmwareStartAt) { + this.updateFirmwareStartAt = updateFirmwareStartAt; + } + + public Object getLastActivityAt() { + return lastActivityAt; + } + + public void setLastActivityAt(Object lastActivityAt) { + this.lastActivityAt = lastActivityAt; + } +} diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/dto/SignIn.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/dto/SignIn.java new file mode 100644 index 0000000000000..b755adc2df4e4 --- /dev/null +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/dto/SignIn.java @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.iaqualink.internal.v2.api.dto; + +/** + * Object used to login to service. + * + * @author Jonathan Gilbert - Initial contribution + * + */ +public class SignIn { + + private String apiKey; + + private String email; + + private String password; + + public SignIn(String apiKey, String email, String password) { + super(); + this.apiKey = apiKey; + this.email = email; + this.password = password; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/dto/UserPoolOAuth.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/dto/UserPoolOAuth.java new file mode 100644 index 0000000000000..ba54b0ebc0b8c --- /dev/null +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/dto/UserPoolOAuth.java @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.iaqualink.internal.v2.api.dto; + +import com.google.gson.annotations.SerializedName; + +/** + * Access credentials. + * + * @author Jonathan Gilbert - Initial contribution + */ +public class UserPoolOAuth { + + @SerializedName("AccessToken") + String AccessToken; + + @SerializedName("ExpiresIn") + int ExpiresIn; + + @SerializedName("IdToken") + String IdToken; + + @SerializedName("RefreshToken") + String RefreshToken; + + @SerializedName("TokenType") + String TokenType; + + public void setAccessToken(String AccessToken) { + this.AccessToken = AccessToken; + } + + public String getAccessToken() { + return AccessToken; + } + + public void setExpiresIn(int ExpiresIn) { + this.ExpiresIn = ExpiresIn; + } + + public int getExpiresIn() { + return ExpiresIn; + } + + public void setIdToken(String IdToken) { + this.IdToken = IdToken; + } + + public String getIdToken() { + return IdToken; + } + + public void setRefreshToken(String RefreshToken) { + this.RefreshToken = RefreshToken; + } + + public String getRefreshToken() { + return RefreshToken; + } + + public void setTokenType(String TokenType) { + this.TokenType = TokenType; + } + + public String getTokenType() { + return TokenType; + } +} diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/mapping/ChannelDef.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/mapping/ChannelDef.java new file mode 100644 index 0000000000000..f79b4d56e6f5c --- /dev/null +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/mapping/ChannelDef.java @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.iaqualink.internal.v2.api.mapping; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +import com.jayway.jsonpath.DocumentContext; +import com.jayway.jsonpath.JsonPath; +import com.jayway.jsonpath.PathNotFoundException; + +import net.minidev.json.JSONArray; + +/** + * Definition of a channel, paired with a JSON path to get or set associated values. + * + * @author Jonathan Gilbert - Initial contribution + */ +@NonNullByDefault +public class ChannelDef { + String id; + String label; + String itemType; + String typeId; + JsonPath valuePath; + @Nullable + JsonPath appliesPath; + + public ChannelDef(String id, String label, String itemType, String typeId, String valuePath, + @Nullable String appliesPath) { + this.id = id; + this.label = label; + this.itemType = itemType; + this.typeId = typeId; + this.valuePath = JsonPath.compile(valuePath); + + if (!this.valuePath.isDefinite()) { + throw new IllegalArgumentException("valuePath must be definite: " + valuePath); + } + + if (appliesPath != null) { + this.appliesPath = JsonPath.compile(appliesPath); + } + } + + public ChannelDef(String id, String label, String itemType, String typeId, String valuePath) { + this(id, label, itemType, typeId, valuePath, null); + } + + public String id() { + return id; + } + + public String itemType() { + return itemType; + } + + public String label() { + return label; + } + + public String typeId() { + return typeId; + } + + public @Nullable Object value(DeviceState deviceState) { + return tryValue(deviceState, valuePath); + } + + public boolean appliesToState(DeviceState deviceState) { + JsonPath pathToUse = Objects.requireNonNullElse(appliesPath, valuePath); + return !isEmptyResult(tryValue(deviceState, pathToUse)); + } + + public DeviceState updateJson(Object value) { + DocumentContext ctx = JsonPath.parse("{}"); + String[] segments = valuePath.getPath().split("(?=\\[)"); + + for (int i = 1; i < segments.length; i++) { + String subpath = String.join("", Arrays.copyOfRange(segments, 0, i)); + Object toPut = i == segments.length - 1 ? value : new HashMap<>(); + String key = segments[i].substring(2, segments[i].length() - 2); + ctx = ctx.put(subpath, key, toPut); + } + + return new DeviceState(ctx); + } + + private static boolean isEmptyResult(@Nullable Object result) { + return result == null || result instanceof JSONArray && ((JSONArray) result).isEmpty(); + } + + private static @Nullable Object tryValue(DeviceState deviceState, JsonPath path) { + try { + return deviceState.documentContext.read(path); + } catch (PathNotFoundException pnfe) { + return null; + } + } +} diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/mapping/Channels.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/mapping/Channels.java new file mode 100644 index 0000000000000..3983e57580604 --- /dev/null +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/mapping/Channels.java @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.iaqualink.internal.v2.api.mapping; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Defines possible channels, based on JSON path. + * + * @author Jonathan Gilbert - Initial contribution + */ +@NonNullByDefault +public class Channels { + public static Collection all() { + return Stream.of(schedules(), swcs()).flatMap(Collection::stream).collect(Collectors.toList()); + } + + public static Collection schedules() { + return Stream.of(1, 2, 3, 4, 10).flatMap(id -> scheduleById(id).stream()).collect(Collectors.toList()); + } + + public static Collection swcs() { + return Stream.of(0, 1, 2, 3, 4).flatMap(id -> swcById(id).stream()).collect(Collectors.toList()); + } + + public static Collection sensorsForSwc(int swcId) { + return Stream.of(1, 2, 3).flatMap(id -> sensorById(swcId, id).stream()).collect(Collectors.toList()); + } + + public static Collection sensorById(int swcId, int id) { + return List.of(new ChannelDef(String.format("SWC%d_TempSensor%d", swcId, id), + String.format("Salt Water Chlorinator %d Temperature Sensor %d", swcId, id), "Number:Temperature", + "temperature", String.format("$.state.reported.equipment.swc_%d.sns_%d.value", swcId, id), + String.format( + "$.state.reported.equipment.swc_%d.sns_%d[?(@.state == 1 && @.sensor_type == \"Water temp\")]", + swcId, id)), + new ChannelDef(String.format("SWC%d_PhSensor%d", swcId, id), + String.format("Salt Water Chlorinator %d Ph Sensor %d", swcId, id), "Number", "chemical", + String.format("$.state.reported.equipment.swc_%d.sns_%d.value", swcId, id), + String.format( + "$.state.reported.equipment.swc_%d.sns_%d[?(@.state == 1 && @.sensor_type == \"Ph\")]", + swcId, id))); + } + + public static Collection swcById(int id) { + Collection rv = new ArrayList<>(); + + rv.add(new ChannelDef(String.format("SWC%d_Boost", id), String.format("Salt Water Chlorinator %d Boost", id), + "Switch", "equipment-switch", String.format("$.state.reported.equipment.swc_%d.boost", id))); + rv.add(new ChannelDef(String.format("SWC%d_Boost_Time", id), + String.format("Salt Water Chlorinator %d Boost Time", id), "Number:Time", "duration", + String.format("$.state.reported.equipment.swc_%d.boost_time", id))); + + rv.add(new ChannelDef(String.format("SWC%d_Low", id), String.format("Salt Water Chlorinator %d Low", id), + "Switch", "equipment-switch", String.format("$.state.reported.equipment.swc_%d.low", id))); + rv.add(new ChannelDef(String.format("SWC%d_Production", id), + String.format("Salt Water Chlorinator %d Production", id), "Switch", "readonly-switch", + String.format("$.state.reported.equipment.swc_%d.production", id))); + rv.add(new ChannelDef(String.format("SWC%d_Filter_Pump", id), + String.format("Salt Water Chlorinator %d Filter Pump", id), "Switch", "equipment-switch", + String.format("$.state.reported.equipment.swc_%d.filter_pump.state", id))); + rv.add(new ChannelDef(String.format("SWC%d_SaltLevel", id), + String.format("Salt Water Chlorinator %d Salt Level", id), "Number", "chemical", + String.format("$.state.reported.equipment.swc_%d.swc", id))); + + rv.addAll(sensorsForSwc(id)); + + return rv; + } + + public static Collection scheduleById(int id) { + return List.of( + new ChannelDef(String.format("Schedule%d_Active", id), + String.format("Salt Water Chlorinator Schedule %s Active", id), "Switch", "readonly-switch", + String.format("$.state.reported.schedules.sch%d.active", id)), + new ChannelDef(String.format("Schedule%d_Enabled", id), + String.format("Salt Water Chlorinator Schedule %s Enabled", id), "Switch", "equipment-switch", + String.format("$.state.reported.schedules.sch%d.enabled", id)), + new ChannelDef(String.format("Schedule%d_Start", id), + String.format("Salt Water Chlorinator Schedule %s Start", id), "String", "schedule-time", + String.format("$.state.reported.schedules.sch%d.timer.start", id)), + new ChannelDef(String.format("Schedule%d_End", id), + String.format("Salt Water Chlorinator Schedule %s End", id), "String", "schedule-time", + String.format("$.state.reported.schedules.sch%d.timer.end", id))); + } + + public static Collection appliesToState(Collection from, DeviceState newState) { + return from.stream().filter(def -> def.appliesToState(newState)).collect(Collectors.toList()); + } +} diff --git a/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/mapping/DeviceState.java b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/mapping/DeviceState.java new file mode 100644 index 0000000000000..6da7fe047b7d9 --- /dev/null +++ b/bundles/org.openhab.binding.iaqualink/src/main/java/org/openhab/binding/iaqualink/internal/v2/api/mapping/DeviceState.java @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.iaqualink.internal.v2.api.mapping; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.jayway.jsonpath.DocumentContext; +import com.jayway.jsonpath.JsonPath; + +/** + * This class wraps JSONPath document context, insulating clients from needing to understand the underlying + * JSONPath library and API, and ensuring that parsed JSON is cached where possible. + * + * @author Jonathan Gilbert + */ +@NonNullByDefault +public class DeviceState { + DocumentContext documentContext; + + DeviceState(DocumentContext documentContext) { + this.documentContext = documentContext; + } + + public String jsonString() { + return documentContext.jsonString(); + } + + public static DeviceState parse(String jsonString) { + return new DeviceState(JsonPath.parse(jsonString)); + } +} diff --git a/bundles/org.openhab.binding.iaqualink/src/main/resources/OH-INF/thing/iAqualink.xml b/bundles/org.openhab.binding.iaqualink/src/main/resources/OH-INF/thing/iAqualink.xml index cd98d538ab767..4dc66ecec2496 100644 --- a/bundles/org.openhab.binding.iaqualink/src/main/resources/OH-INF/thing/iAqualink.xml +++ b/bundles/org.openhab.binding.iaqualink/src/main/resources/OH-INF/thing/iAqualink.xml @@ -120,6 +120,39 @@ + + + An iAquaLink pool control thing represents an iAquaLink pool controller for Jandy/Zodiac systems. + This new + version currently only supports Zodiac Hydroxinator systems. + + + + + The username to use when connecting to an iAqualink Account + + + password + + The password to use when connecting to an iAqualink Account + + + + Optionally specify the serial number of the controller which can be found on the iAquaLink Owner's + Center. This is only useful if you have more then one controller (pool) associated with your account. Leave blank + to have the first controller used. + + + + + Optionally specify the API key used for access. This is only useful for debugging or if the API key is + changed by the vendor + + EOOEMOW4YR6QNB07 + + + + @@ -328,4 +361,24 @@ Other + + + String + + Other + + + + Number:Time + + Other + + + + Switch + + The current (readonly) state + + + diff --git a/bundles/org.openhab.binding.iaqualink/src/test/java/org/openhab/binding/iaqualink/internal/v2/api/mapping/ChannelMapperTest.java b/bundles/org.openhab.binding.iaqualink/src/test/java/org/openhab/binding/iaqualink/internal/v2/api/mapping/ChannelMapperTest.java new file mode 100644 index 0000000000000..da758353b9464 --- /dev/null +++ b/bundles/org.openhab.binding.iaqualink/src/test/java/org/openhab/binding/iaqualink/internal/v2/api/mapping/ChannelMapperTest.java @@ -0,0 +1,472 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.iaqualink.internal.v2.api.mapping; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Collection; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; + +/** + * Basic testing for extraction of channel definitions and conversion of updates into JSON + * + * @author Jonathan Gilbert - Initial contribution + */ +@NonNullByDefault +class ChannelMapperTest { + + @Test + void testFindSWC0BoostChannel() { + DeviceState document = DeviceState.parse(sampleJson); + + Collection channels = Channels.appliesToState(Channels.all(), document); + + assertFalse(channels.isEmpty(), "Should have at least one channel"); + assertTrue(channels.stream().anyMatch(def -> def.id().equals("SWC0_Boost")), "Should have SWC0"); + assertTrue(channels.stream().anyMatch(def -> def.id().equals("SWC0_TempSensor3")), + "Should have SWC0_TempSensor3"); + assertTrue(channels.stream().anyMatch(def -> def.id().equals("SWC0_PhSensor1")), "Should have SWC0_PhSensor1"); + } + + @Test + void testSimpleChannelUpdate() { + ChannelDef testChannel = new ChannelDef("test", "test", "Switch", "test", "$.state.val"); + String updateJson = testChannel.updateJson(2).jsonString(); + assertEquals("{\"state\":{\"val\":2}}", updateJson); + } + + String sampleJson = """ + { + "state":{ + "reported":{ + "aws":{ + "status":"connected", + "timestamp":1729779988154, + "session_id":"Test-Session-UUID" + }, + "vr":"V85E4", + "vr_esp":"V85E4", + "equipment":{ + "swc_0":{ + "vr":"V85R70", + "version":"V1", + "lang":0, + "amp":1, + "aux230":0, + "sn":"TEST_SN", + "ph_only":0, + "dual_link":0, + "temp":1, + "vsp":1, + "exo_state":1, + "production":1, + "low":0, + "boost":0, + "boost_time":"24:00", + "error_state":0, + "error_code":0, + "aux_1":{ + "type":"none", + "state":0, + "mode":0, + "color":0 + }, + "aux_2":{ + "type":"none", + "state":0, + "mode":0, + "color":0 + }, + "filter_pump":{ + "type":1, + "state":1 + }, + "orp_sp":700, + "ph_sp":72, + "swc":50, + "swc_low":10, + "sns_1":{ + "sensor_type":"Ph", + "state":1, + "value":70 + }, + "sns_2":{ + "sensor_type":"Orp", + "state":0, + "value":0 + }, + "sns_3":{ + "sensor_type":"Water temp", + "state":1, + "value":27 + } + } + }, + "schedules":{ + "supported":5, + "programmed":2, + "sch1":{ + "id":"sch_1", + "name":"Salt Water Chlorinator 1", + "endpoint":"swc_1", + "enabled":1, + "active":0, + "timer":{ + "start":"09:00", + "end":"14:00" + } + }, + "sch2":{ + "id":"sch_2", + "name":"Salt Water Chlorinator 2", + "endpoint":"swc_2", + "enabled":0, + "active":0, + "timer":{ + "start":"00:00", + "end":"00:00" + } + }, + "sch3":{ + "enabled":1, + "active":0, + "timer":{ + "start":"09:00", + "end":"15:00" + }, + "id":"sch_3", + "name":"Filter Pump 1", + "endpoint":"ssp_1" + }, + "sch10":{ + "id":"sch_10", + "name":"Aux 2", + "endpoint":"aux2", + "enabled":0, + "active":0, + "timer":{ + "start":"00:00", + "end":"00:00" + } + }, + "sch4":{ + "id":"sch_4", + "name":"Filter Pump 2", + "endpoint":"ssp_2", + "enabled":0, + "active":0, + "timer":{ + "start":"00:00", + "end":"00:00" + } + } + } + } + }, + "metadata":{ + "reported":{ + "aws":{ + "status":{ + "timestamp":1729779988 + }, + "timestamp":{ + "timestamp":1729779988 + }, + "session_id":{ + "timestamp":1729779988 + } + }, + "vr":{ + "timestamp":1729779991 + }, + "vr_esp":{ + "timestamp":1729779992 + }, + "equipment":{ + "swc_0":{ + "vr":{ + "timestamp":1728437680 + }, + "version":{ + "timestamp":1728437681 + }, + "lang":{ + "timestamp":1728437683 + }, + "amp":{ + "timestamp":1728437683 + }, + "aux230":{ + "timestamp":1728437683 + }, + "sn":{ + "timestamp":1728437683 + }, + "ph_only":{ + "timestamp":1728437684 + }, + "dual_link":{ + "timestamp":1728437684 + }, + "temp":{ + "timestamp":1728437630 + }, + "vsp":{ + "timestamp":1728437630 + }, + "exo_state":{ + "timestamp":1728437633 + }, + "production":{ + "timestamp":1730013707 + }, + "low":{ + "timestamp":1730013702 + }, + "boost":{ + "timestamp":1728437636 + }, + "boost_time":{ + "timestamp":1728437637 + }, + "error_state":{ + "timestamp":1728437638 + }, + "error_code":{ + "timestamp":1728437639 + }, + "aux_1":{ + "type":{ + "timestamp":1728437641 + }, + "state":{ + "timestamp":1728437642 + }, + "mode":{ + "timestamp":1728437643 + }, + "color":{ + "timestamp":1728437644 + } + }, + "aux_2":{ + "type":{ + "timestamp":1728437645 + }, + "state":{ + "timestamp":1728989966 + }, + "mode":{ + "timestamp":1728437647 + }, + "color":{ + "timestamp":1728437648 + } + }, + "filter_pump":{ + "type":{ + "timestamp":1728437649 + }, + "state":{ + "timestamp":1730013704 + } + }, + "orp_sp":{ + "timestamp":1728437653 + }, + "ph_sp":{ + "timestamp":1728437654 + }, + "swc":{ + "timestamp":1728512953 + }, + "swc_low":{ + "timestamp":1728437656 + }, + "sns_1":{ + "sensor_type":{ + "timestamp":1728437657 + }, + "state":{ + "timestamp":1728437657 + }, + "value":{ + "timestamp":1728437657 + } + }, + "sns_2":{ + "sensor_type":{ + "timestamp":1728437658 + }, + "state":{ + "timestamp":1728437658 + }, + "value":{ + "timestamp":1728437658 + } + }, + "sns_3":{ + "sensor_type":{ + "timestamp":1730014119 + }, + "state":{ + "timestamp":1730014119 + }, + "value":{ + "timestamp":1730014119 + } + } + } + }, + "schedules":{ + "supported":{ + "timestamp":1728437660 + }, + "programmed":{ + "timestamp":1728437660 + }, + "sch1":{ + "id":{ + "timestamp":1728437661 + }, + "name":{ + "timestamp":1728437661 + }, + "endpoint":{ + "timestamp":1728437661 + }, + "enabled":{ + "timestamp":1729997985 + }, + "active":{ + "timestamp":1729997985 + }, + "timer":{ + "start":{ + "timestamp":1728989817 + }, + "end":{ + "timestamp":1728989817 + } + } + }, + "sch2":{ + "id":{ + "timestamp":1728437665 + }, + "name":{ + "timestamp":1728437665 + }, + "endpoint":{ + "timestamp":1728437665 + }, + "enabled":{ + "timestamp":1728437666 + }, + "active":{ + "timestamp":1728437666 + }, + "timer":{ + "start":{ + "timestamp":1728437667 + }, + "end":{ + "timestamp":1728437667 + } + } + }, + "sch3":{ + "enabled":{ + "timestamp":1730001588 + }, + "active":{ + "timestamp":1730001588 + }, + "timer":{ + "start":{ + "timestamp":1728442819 + }, + "end":{ + "timestamp":1728442819 + } + }, + "id":{ + "timestamp":1728437668 + }, + "name":{ + "timestamp":1728437668 + }, + "endpoint":{ + "timestamp":1728437668 + } + }, + "sch10":{ + "id":{ + "timestamp":1728437676 + }, + "name":{ + "timestamp":1728437676 + }, + "endpoint":{ + "timestamp":1728437676 + }, + "enabled":{ + "timestamp":1728989967 + }, + "active":{ + "timestamp":1728989967 + }, + "timer":{ + "start":{ + "timestamp":1728437678 + }, + "end":{ + "timestamp":1728437678 + } + } + }, + "sch4":{ + "id":{ + "timestamp":1728437671 + }, + "name":{ + "timestamp":1728437671 + }, + "endpoint":{ + "timestamp":1728437671 + }, + "enabled":{ + "timestamp":1728437673 + }, + "active":{ + "timestamp":1728437673 + }, + "timer":{ + "start":{ + "timestamp":1728437674 + }, + "end":{ + "timestamp":1728437674 + } + } + } + } + } + }, + "version":1680, + "timestamp":1730014146 + } + """; +}