<artifactId>org.openhab.binding.lifx</artifactId>
<version>${project.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.openhab.addons.bundles</groupId>
+ <artifactId>org.openhab.binding.linktap</artifactId>
+ <version>${project.version}</version>
+ </dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.linky</artifactId>
--- /dev/null
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
+
+== Third-party Content
+
+jsoup
+* License: MIT License
+* Project: https://jsoup.org/
+* Source: https://github.com/jhy/jsoup
\ No newline at end of file
--- /dev/null
+# LinkTap Binding
+
+This binding is for [Link-Tap](https://www.link-tap.com/) devices.
+
+**This is for communication over a local area network, that prevents direct access to your gateway / openHAB instance from the internet.
+E.g. behind a router**
+
+The method of interaction this binding supports is:
+
+**Program and execution of the watering plan within the application**
+
+The currently supported capabilities include where supported by the gateway / device:
+
+- Time synchronisation to openHAB
+- Child lock controls
+- Monitoring and dismissal for device alarms (Water Cut, etc.)
+- Monitoring of sensor states (Battery, Zigbee Signal, Flow Meters Statistics, etc.)
+- Enable watering based on time duration / volume limits
+- Shutdown of active watering
+
+## Requirements
+
+A LinkTap gateway device such as the GW_02, in order for openHAB to connect to the system, as a gateway.
+Older GW_01 gateway devices have not been tested but should work with a static IP setup.
+
+The recommended minimum version of the firmware is:
+
+- **GW_01** is to start with **at least S609**
+- **GW_02** is to start with **at least G609**
+
+## Connection Options
+
+LinkTap supports MQTT and a direct interaction via HTTP.
+This binding directly interacts with LinkTap's gateway devices using the Local HTTP API (HTTP).
+The binding connects to the gateway's directly, and the Gateway is configured automatically to push updates to openHAB if it has a HTTP configured server.
+(Note HTTPS is not supported).
+
+Should the Gateway device's not be able to connect to the binding it automatically falls-back to a polling implementation (15 second cycle).
+The gateway supports 1 Local HTTP API, for an ideal behavior the Gateway should be able to connect to openHAB on a HTTP port by its IP, and only a single openHAB instance should be connected to a Gateway.
+It is recommended that you use **static IP's** for this binding, **for both openHAB and the Gateway device(s)** regardless of the gateway's model.
+
+If dynamic IPs are used for the gateway, the mDNS address is recommended to be used.
+This can be found when running a manual scan, for LinkTap Gateways.
+This will remove any DNS caching related issues, depending on your setup.
+
+## Supported Things
+
+This binding supports the follow thing types:
+
+| Thing Type | Thing Type UID | Discovery | Description |
+|------------|----------------|--------------------|------------------------------------------------------------------|
+| Bridge | gateway | Manual / Automatic | A connection to a LinkTap Gateway device |
+| Thing | device | Automatic | A end device such as one of the four controlled values on the Q1 |
+
+**NOTE** This binding was developed and tested using a GW-02 gateway with a Q1 device.
+
+## Discovery
+
+### Gateways
+
+If mDNS has been enabled on the Gateway device via it's webpage, then the gateway(s) will be discovered, and appear in the inbox when a manual scan is run when adding a LinkTap Gateway.
+It is however recommended to use **static IP addresses** and add the gateways directly using the IP address.
+
+### Devices
+
+Once connected to a LinkTap gateway, the binding will listen for updates of new devices and add them, to the inbox.
+If the gateway cannot publish to openHAB, then the gateway is checked every 2 minutes for new devices, and they are added to the inbox when discovered.
+
+## Binding Configuration
+
+### Gateway Configuration
+
+| Name | Type | Description | Recommended Values | Required | Advanced |
+|-----------------------|--------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------|----------|----------|
+| host | String | The hostname / IP address of the gateway device | | Yes | No |
+| username | String | The username if set for the gateway device | | No | No |
+| password | String | The password if set for the gateway device | | No | No |
+| enableMDNS | Switch | On connection whether the mDNS responder should be enabled on the gateway device | true | No | Yes |
+| enforceProtocolLimits | Switch | If true data outside of the allowed ranges against the protocol will be logged and not sent | true | No | Yes |
+| enableJSONComms | Switch | false by default for backwards compatibility, if using up to date firmware with no other local network applications set this to true, for more efficient communications | true | No | Yes |
+
+**NOTE** When enableMDNS is enabled, upon connection to the gateway option "Enable mDNS responder" is switched on.
+
+### Device Configuration
+
+| Name | Type | Description | Recommended Values | Required | Advanced |
+|--------------|---------|-----------------------------------------------------------------------|--------------------|----------|----------|
+| deviceId | String | The Device Id for the device under the gateway | | No (A,B) | No |
+| deviceName | String | The name allocated to the device by the app. (Must be unique if used) | | No (B) | No |
+| enableAlerts | boolean | On connection whether the device should be configured to send alerts | true | No | Yes |
+
+**NOTE:**
+
+(A) It is recommended to use the Device Id, for locating devices.
+This can be found in the LinkTap mobile application under Settings->TapLinker / ValveLinker, e.g.
+
+- ValueLinker_1 (D71BC52F004B1200_1-xxxx)
+ - has Device Id "ValveLinker_1"
+ - has Device Name D71BC52F004B1200_1
+
+(B) Either a **deviceId or deviceName is required** for the device to be located and used.
+
+## Channels
+
+| Name | Type | Description | Representation | Write Action | Note |
+|-------------------|---------------------------|-------------------------------------------------------------------------------------|----------------|------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------|
+| water-cut | Switch | Water cut-off alert | Alert | Dismiss alert | |
+| shutdown-failure | Switch | The device has failed to close the valve | Alert | Dismiss alert | |
+| high-flow | Switch | Unusually high flow rate detected alert | Alert | Dismiss alert | |
+| low-flow | Switch | Unusually low flow rate detected alert | Alert | Dismiss alert | |
+| fall-status | Switch | The device has fallen | Alert | Dismiss alert | |
+| mode | Text | The current watering plan mode | R | | |
+| flm-linked | Switch | The device has a included flow meter | R | | |
+| rf-linked | Switch | Is the device RF linked | R | | |
+| signal | Number:Dimensionless | Reception Signal Strength | R | | |
+| battery | Number:Dimensionless | Battery Remaining Level | R | | |
+| flow-rate | Number:VolumetricFlowRate | Current water flow rate | R | | |
+| volume | Number:Volume | Accumulated volume of current watering cycle | R | | |
+| eco-final | Switch | In ECO mode this is true when the final ON watering on segment is running | R | | |
+| remaining | Number:Time | Remaining duration of the current watering cycle | R | | |
+| duration | Number:Time | Total duration of current watering cycle | R | | |
+| watering | Switch | Active watering status | RW | True - Start immediate watering, False - Stops the current watering process, the next planned watering will run as scheduled | |
+| manual-watering | Switch | Manual watering mode status | R | | |
+| child-lock | Text | The child lock mode | RW | Unlocked - Button enabled, Partially locked -> 3 second push required, Completely locked -> Button disabled | If the GW has internet connectivity settings will be reset when it sync's to TapLink's servers. |
+| oh-dur-limit | Number:Time | Max duration allowed for the immediate watering | W | Max Time duration for "Start immediate watering" | |
+| oh-vol-limit | Number:Volume | Max Volume limit for immediate watering | W | Max Volume for "Start immediate watering" | |
+| plan-pause-enable | Switch | When ON will pause the current watering plan for an hour every 55 minutes | RW | Pause current watering plan every 55 mins for one hour | This disables the TapLink Watering Plan from being run, it is not reflected in the mobile app. |
+| plan-resume-time | DateTime | Displays when the last pause issued will expiry, resuming the current watering plan | R | | |
+| watering-plan-id | Text | Displays the current watering plan id | R | | |
+
+**NOTE:**
+There are 4 different areas of channels:
+
+- R (Read Only Data)
+ - These represent data published by the device
+- Alerts
+ - These are switches that are set to ON by the device when an alert condition is detected, such as a Water Cut.
+ - The alert can be dismissed by setting the switch to OFF
+- RW (Read Write Data)
+ - Provides the ability to read data
+ - Provides the ability to set a relevant state to the data
+- W Data
+ - Provides parameter values for the named action, it is stored within openHAB is not read from the device
+ - E.g. Start Immediate Watering
+ - Can be limited by a time duration - ohDurLimit
+ - If a flow meter is attached can be limited by a volume limit - ohVolLimit
+
+## Full Example
+
+### Thing Configuration
+
+- **Gateway Model**: GW_02
+- **Device Model**: Q1
+
+```java
+Bridge linktap:gateway:home "LinkTap GW02" [ host="192.168.0.21", enableMDNS=true, enableJSONComms=false, enforceProtocolLimits=true ] {
+ Thing device TapValve1 "Outdoor Tap 1" [ id="D71BC52E985B1200_1", name="ValveLinker_1", enableAlerts=true ]
+ Thing device TapValve2 "Outdoor Tap 2" [ id="D71BC52E985B1200_2", name="ValveLinker_2", enableAlerts=true ]
+ Thing device TapValve3 "Outdoor Tap 3" [ id="D71BC52E985B1200_3", name="ValveLinker_3", enableAlerts=true ]
+ Thing device TapValve4 "Outdoor Tap 4" [ id="D71BC52E985B1200_4", name="ValveLinker_4", enableAlerts=true ]
+}
+```
+
+### Item Configuration
+
+```java
+Number:Dimensionless Tap1BatteryLevel "Tap 1 - Battery Level" <batterylevel> ["Point"] { channel="linktap:device:home:tapValve1:battery",unit="%%" }
+Number:Dimensionless Tap1SignalLevel "Tap 1 - Signal Level" <qualityofservice> ["Point"] { channel="linktap:device:home:tapValve1:signal",unit="%%" }
+Switch Tap1RfLinked "Tap 1 - RF Linked" <switch> ["Point"] { channel="linktap:device:home:tapValve1:rf-linked"}
+Switch Tap1FlmLinked "Tap 1 - FLM Linked" <switch> ["Point"] { channel="linktap:device:home:tapValve1:flm-linked"}
+Switch Tap1WaterCutAlert "Tap 1 - Water Cut Alert" <alarm> ["Point"] { channel="linktap:device:home:tapValve1:water-cut" }
+Switch Tap1WaterFallAlert "Tap 1 - Fallen Alert" <alarm> ["Point"] { channel="linktap:device:home:tapValve1:fall-status" }
+Switch Tap1WaterValveAlert "Tap 1 - Shutdown Failure Alert" <alarm> ["Point"] { channel="linktap:device:home:tapValve1:shutdown-failure" }
+Switch Tap1WaterLowFlowAlert "Tap 1 - Low Flow Alert" <alarm> ["Point"] { channel="linktap:device:home:tapValve1:low-flow" }
+Switch Tap1WaterHighFlowAlert "Tap 1 - High Flow Alert" <alarm> ["Point"] { channel="linktap:device:home:tapValve1:high-flow" }
+String Tap1ChildLockMode "Tap 1 - Child Lock Mode" <lock> ["Point"] { channel="linktap:device:home:tapValve1:child-lock" }
+Number:VolumetricFlowRate Tap1FlowRate "Tap 1 - Flow Rate" <flow> ["Point"] { channel="linktap:device:home:tapValve1:flow-rate",unit="l/min" }
+Number:Volume Tap1WateringVolume "Tap 1 - Watering Volume" <water> ["Point"] { channel="linktap:device:home:tapValve1:volume",unit="l" }
+Switch Tap1FinalEcoSegment "Tap 1 - Final ECO Segment" <switch> ["Point"] { channel="linktap:device:home:tapValve1:eco-final" }
+Switch Tap1Watering "Tap 1 - Watering" <water> ["Point"] { channel="linktap:device:home:tapValve1:watering" }
+Switch Tap1ManualWatering "Tap 1 - Manual Watering" <water> ["Point"] { channel="linktap:device:home:tapValve1:manual-watering" }
+String Tap1WateringMode "Tap 1 - Watering Mode" <time> ["Point"] { channel="linktap:device:home:tapValve1:mode" }
+Number:Time Tap1TimeDuration "Tap 1 - Current Cycle Duration" <time> ["Point"] { channel="linktap:device:home:tapValve1:duration",unit="s" }
+Number:Time Tap1TimeRemain "Tap 1 - Current Cycle Remaining" <time> ["Point"] { channel="linktap:device:home:tapValve1:remaining",unit="s" }
+Number:Time Tap1WateringCycleDur "Tap 1 - Current Cycle Duration Limit" <time> ["Point"] { channel="linktap:device:home:tapValve1:dur-limit",unit="s" }
+Number:Volume Tap1WateringCycleVol "Tap 1 - Current Cycle Volume Limit" <water> ["Point"] { channel="linktap:device:home:tapValve1:vol-limit",unit="l" }
+Number:Time Tap1ManTimeLimit "Tap 1 - Instant On Duration Limit" <time> ["Point"] { channel="linktap:device:home:tapValve1:oh-dur-limit",unit="s" }
+Number:Volume Tap1ManVolLimit "Tap 1 - Instant On Volume Limit" <water> ["Point"] { channel="linktap:device:home:tapValve1:oh-vol-limit",unit="l" }
+Switch Tap1PauseWateringPlan "Tap 1 - Pause Current Plan" <time> ["Point"] { channel="linktap:device:home:tapValve1:plan-pause-enable" }
+DateTime Tap1PauseExpiry "Tap 1 - Pause Expiry" <calendar> ["Point"] { channel="linktap:device:home:tapValve1:plan-resume-time" }
+String Tap1WateringPlanId "Tap 1 - Watering Plan Id" <calendar> ["Point"] { channel="linktap:device:home:tapValve1:watering-plan-id" }
+```
+
+### Sitemap Configuration
+
+```perl
+Text item=Tap1BatteryLevel
+Switch item=Tap1WaterCutAlert
+Switch item=Tap1WaterFallAlert
+Switch item=Tap1WaterValveAlert
+Switch item=Tap1WaterLowFlowAlert
+Switch item=Tap1WaterHighFlowAlert
+Text item=Tap1ChildLockMode
+Text item=Tap1FlowRate label="Tap 1 - Flow Rate [%.0f %unit%]"
+Text item=Tap1WateringVolume label="Tap 1 - Watering Volume [%.0f %unit%]"
+Text item=Tap1FinalEcoSegment label="Tap 1 - Final Segment [%s]"
+Switch item=Tap1Watering
+Switch item=Tap1ManualWatering
+Text item=Tap1WateringMode
+Text item=Tap1TimeDuration label="Tap 1 - Time Duration [%.0f %unit%]"
+Text item=Tap1TimeRemain label="Tap 1 - Time Remaining [%.0f %unit%]"
+Text item=Tap1WateringCycleDur label="Tap 1 - Cycle Duration [%.0f %unit%]"
+Text item=Tap1WateringCycleVol label="Tap 1 - Cycle Volume [%.0f %unit%]"
+Slider item=Tap1ManTimeLimit minValue=3 maxValue=86340 step=30 releaseOnly label="Tap 1 - Instant On Time Limit [%.0f %unit%]"
+Slider item=Tap1ManVolLimit minValue=1 maxValue=5000 step=1 releaseOnly label="Tap 1 - Instant On Volume Limit [%.0f %unit%]"
+Switch item=Tap1PauseWateringPlan
+Text item=Tap1PauseExpiry
+Text item=Tap1WateringPlanId
+```
+
+#### Other Models
+
+Please check the [Link-Tap](https://www.link-tap.com/) website.
+Presently at this location [here](https://www.link-tap.com/#!/wireless-water-timer) is a chart that shows the features available for the products.
+If a product such as the G1S is used, it will not support flow based commands or readings.
+In this case exclude the volume based Items and Sitemap entries.
+
+Note in cases such as the G1S where flow meters are not included, or are disconnected, the instant watering will be based solely on the time arguments.
+Flow data would as expected not be updated.
+
+## Thanks To
+
+A note goes out to Bill at Link-Tap who has been extremely responsive in providing specifications, and quick fixes for a single issue noticed, as well as answering many questions about the behaviours of untested devices.
+
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.openhab.addons.bundles</groupId>
+ <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+ <version>4.3.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>org.openhab.binding.linktap</artifactId>
+
+ <name>openHAB Add-ons :: Bundles :: LinkTap Binding</name>
+
+ <properties>
+ <jsoup.version>1.15.4</jsoup.version>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.jsoup</groupId>
+ <artifactId>jsoup</artifactId>
+ <version>${jsoup.version}</version>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <version>4.13.2</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+</project>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.linktap-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
+ <repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
+
+ <feature name="openhab-binding-linktap" description="LinkTap Binding" version="${project.version}">
+ <feature>openhab-runtime-base</feature>
+ <bundle dependency="true">mvn:org.jsoup/jsoup/1.15.4</bundle>
+ <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.linktap/${project.version}</bundle>
+ </feature>
+</features>
--- /dev/null
+/**
+ * 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.linktap.configuration;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link LinkTapBridgeConfiguration} class contains fields mapping bridge configuration parameters.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class LinkTapBridgeConfiguration {
+
+ public String host = "";
+ public String username = "";
+ public String password = "";
+ public boolean enableMDNS = true;
+ public boolean enableJSONComms = false;
+ public boolean enforceProtocolLimits = true;
+}
--- /dev/null
+/**
+ * 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.linktap.configuration;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link LinkTapDeviceConfiguration} class contains fields mapping the configuration parameters for a LinkTap
+ * device's configuration.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class LinkTapDeviceConfiguration {
+
+ /**
+ * The clear text device name as reported by the API.
+ */
+ public String name = "";
+
+ /**
+ * The device id as stored by the gateway to address the device.
+ */
+ public String id = "";
+
+ /**
+ * If enabled the device, will enable all alerts during device initialization.
+ */
+ public boolean enableAlerts = true;
+}
--- /dev/null
+/**
+ * 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.linktap.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link DeviceMetaDataUpdatedHandler} enables call-backs for when the device meta-data is updated from a bridge.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public interface DeviceMetaDataUpdatedHandler {
+ /**
+ * Any registered metadata handlers, will have this
+ * invoked after new configuration data has been retrieved from the GW.
+ *
+ * An example use is for the discovery service to refresh its data based on received
+ * new configuration data of devices attached to a GW.
+ */
+ void handleMetadataRetrieved(LinkTapBridgeHandler handler);
+}
--- /dev/null
+/**
+ * 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.linktap.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link Firmware} class defines the firmware version.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class Firmware {
+ String raw;
+ int buildVer;
+ int hwVer;
+
+ public Firmware(final @Nullable String fwVersion) {
+ raw = "S00000000";
+ hwVer = 0;
+ buildVer = 0;
+
+ if (fwVersion == null || fwVersion.length() < 7) {
+ return;
+ } else {
+ raw = fwVersion;
+ buildVer = Integer.parseInt(raw.substring(1, 7));
+ }
+
+ switch (fwVersion.charAt(0)) {
+ case 'G':
+ hwVer = 2;
+ break;
+ case 'S':
+ hwVer = 1;
+ break;
+ default:
+ break;
+ }
+ }
+
+ public boolean supportsLocalConfig() {
+ return buildVer >= 60883;
+ }
+
+ public boolean supportsMDNS() {
+ return buildVer >= 60880;
+ }
+
+ public String generateTestedRevisionForHw(final int versionNo) {
+ return String.format("%c%05d", raw.charAt(0), versionNo);
+ }
+
+ public String getRecommendedMinVer() {
+ return generateTestedRevisionForHw(60883);
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Defines a interface that Things under the Bridge can implement to receive
+ * callbacks, when the bridges configuration data has been updated.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public interface IBridgeData {
+
+ /**
+ * Any things under a Bridge that implement this interface, will have this
+ * invoked after new configuration data has been retrieved from the GW.
+ */
+ void handleBridgeDataUpdated();
+}
--- /dev/null
+/**
+ * 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.linktap.internal;
+
+import java.lang.reflect.Type;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.linktap.protocol.frames.WaterMeterStatus;
+import org.openhab.core.thing.ThingTypeUID;
+
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.reflect.TypeToken;
+
+/**
+ * The {@link LinkTapBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class LinkTapBindingConstants {
+
+ private static final Type DEVICE_STATUS_CLASS_LIST_TYPE = new TypeToken<List<WaterMeterStatus.DeviceStatus>>() {
+ }.getType();
+
+ public static final Gson GSON = new GsonBuilder()
+ .registerTypeAdapter(DEVICE_STATUS_CLASS_LIST_TYPE, new WaterMeterStatus.DeviceStatusClassTypeAdapter())
+ .excludeFieldsWithoutExposeAnnotation().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+ .disableHtmlEscaping().create();
+
+ private static final String BINDING_ID = "linktap";
+
+ // List of all Thing Type UIDs
+ public static final ThingTypeUID THING_TYPE_DEVICE = new ThingTypeUID(BINDING_ID, "device");
+ public static final ThingTypeUID THING_TYPE_GATEWAY = new ThingTypeUID(BINDING_ID, "gateway");
+
+ public static final String BRIDGE_PROP_GW_ID = "gatewayId";
+ public static final String BRIDGE_PROP_HW_MODEL = "hardwareModel";
+ public static final String BRIDGE_PROP_GW_VER = "version";
+ public static final String BRIDGE_PROP_MAC_ADDR = "macAddress";
+ public static final String BRIDGE_PROP_HTTP_API_ENABLED = "httpApiEnabled";
+ public static final String BRIDGE_PROP_HTTP_API_EP = "httpApiCallback";
+ public static final String BRIDGE_PROP_VOL_UNIT = "volumeUnit";
+ public static final String BRIDGE_PROP_UTC_OFFSET = "utcOffset";
+ public static final String BRIDGE_CONFIG_HOSTNAME = "host";
+ public static final String BRIDGE_CONFIG_MDNS_ENABLE = "enableMDNS";
+ public static final String BRIDGE_CONFIG_NON_HTML_COMM_ENABLE = "enableJSONComms";
+ public static final String BRIDGE_CONFIG_ENFORCE_COMM_LIMITS = "enforceProtocolLimits";
+
+ public static final String DEVICE_PROP_DEV_ID = "deviceId";
+ public static final String DEVICE_PROP_DEV_NAME = "deviceName";
+ public static final String DEVICE_CONFIG_DEV_ID = "id";
+ public static final String DEVICE_CONFIG_DEV_NAME = "name";
+ public static final String DEVICE_CONFIG_AUTO_ALERTS_ENABLE = "autoEnableAlerts";
+
+ public static final String DEVICE_CHANNEL_WATERING_MODE = "mode";
+ public static final String DEVICE_CHANNEL_IS_MANUAL_MODE = "manual-watering";
+ public static final String DEVICE_CHANNEL_ACTIVE_WATERING = "watering";
+ public static final String DEVICE_CHANNEL_RF_LINKED = "rf-linked";
+ public static final String DEVICE_CHANNEL_FLM_LINKED = "flm-linked";
+ public static final String DEVICE_CHANNEL_FALL_STATUS = "fall-status";
+ public static final String DEVICE_CHANNEL_SHUTDOWN_FAILURE = "shutdown-failure";
+ public static final String DEVICE_CHANNEL_HIGH_FLOW = "high-flow";
+ public static final String DEVICE_CHANNEL_LOW_FLOW = "low-flow";
+ public static final String DEVICE_CHANNEL_FINAL_SEGMENT = "eco-final";
+ public static final String DEVICE_CHANNEL_SIGNAL = "signal";
+ public static final String DEVICE_CHANNEL_BATTERY = "battery";
+ public static final String DEVICE_CHANNEL_WATER_CUT = "water-cut";
+ public static final String DEVICE_CHANNEL_CHILD_LOCK = "child-lock";
+ public static final String DEVICE_CHANNEL_FLOW_RATE = "flow-rate";
+ public static final String DEVICE_CHANNEL_CURRENT_VOLUME = "volume";
+ public static final String DEVICE_CHANNEL_TOTAL_DURATION = "duration";
+ public static final String DEVICE_CHANNEL_REMAIN_DURATION = "remaining";
+ public static final String DEVICE_CHANNEL_FAILSAFE_DURATION = "dur-limit";
+ public static final String DEVICE_CHANNEL_FAILSAFE_VOLUME = "vol-limit";
+ public static final String DEVICE_CHANNEL_OH_VOLUME_LIMIT = "oh-vol-limit";
+ public static final String DEVICE_CHANNEL_OH_DURATION_LIMIT = "oh-dur-limit";
+ public static final String DEVICE_CHANNEL_PAUSE_PLAN_OVERRIDE = "plan-pause-enable";
+ public static final String DEVICE_CHANNEL_PAUSE_PLAN_EXPIRES = "plan-resume-time";
+ public static final String DEVICE_CHANNEL_WATER_PLAN_ID = "watering-plan-id";
+
+ public enum WateringMode {
+
+ /**
+ * OFF (Ordinal 0).
+ */
+ OFF(0, "Off"),
+
+ /**
+ * INSTANT (Ordinal 1).
+ */
+ INSTANT(1, "Instant"),
+
+ /**
+ * CALENDAR (Ordinal 2).
+ */
+ CALENDAR(2, "Calendar"),
+
+ /**
+ * DAY (Ordinal 3).
+ */
+ DAY(3, "Day"),
+
+ /**
+ * ODD_EVEN (Ordinal 4).
+ */
+ ODD_EVEN(4, "Odd-even"),
+
+ /**
+ * INTERVAL (Ordinal 5).
+ */
+ INTERVAL(5, "Interval"),
+
+ /**
+ * MONTH (Ordinal 6).
+ */
+ MONTH(6, "Month");
+
+ private final int value;
+ private final String description;
+
+ private WateringMode(final int value, final String description) {
+ this.value = value;
+ this.description = description;
+ }
+
+ public int getValue() {
+ return value;
+ }
+
+ public String getDesc() {
+ return description;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("%d - %s", value, description);
+ }
+ }
+
+ public enum ChildLockMode {
+
+ /**
+ * UNLOCKED (Ordinal 0).
+ */
+ UNLOCKED(0, "Unlocked"),
+
+ /**
+ * PART_LOCKED (Ordinal 1).
+ */
+ PART_LOCKED(1, "Partially locked"),
+
+ /**
+ * FULLY_LOCKED (Ordinal 2).
+ */
+ FULLY_LOCKED(2, "Completely locked");
+
+ private final int value;
+ private final String description;
+
+ private ChildLockMode(final int value, final String description) {
+ this.value = value;
+ this.description = description;
+ }
+
+ public int getValue() {
+ return value;
+ }
+
+ public String getDesc() {
+ return description;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("%d - %s", value, description);
+ }
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.internal;
+
+import static org.openhab.binding.linktap.internal.LinkTapBindingConstants.*;
+import static org.openhab.binding.linktap.internal.LinkTapBridgeHandler.MDNS_LOOKUP;
+import static org.openhab.binding.linktap.internal.Utils.cleanPrintableChars;
+
+import java.io.UnsupportedEncodingException;
+import java.net.Inet4Address;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Properties;
+import java.util.Random;
+import java.util.Set;
+
+import javax.jmdns.ServiceInfo;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.linktap.protocol.frames.TLGatewayFrame;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.config.discovery.mdns.MDNSDiscoveryParticipant;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.i18n.TranslationProvider;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingRegistry;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.FrameworkUtil;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.slf4j.event.Level;
+
+/**
+ * The {@link LinkTapBridgeDiscoveryService} is an implementation of a discovery service for VeSync devices. The
+ * meta-data is
+ * read by the bridge, and the discovery data updated via a callback implemented by the DeviceMetaDataUpdatedHandler.
+ *
+ * @author David Godyear - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = MDNSDiscoveryParticipant.class, configurationPid = "discovery.linktap")
+public class LinkTapBridgeDiscoveryService implements MDNSDiscoveryParticipant {
+
+ private static final String SERVICE_TYPE = "_http._tcp.local.";
+ private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_GATEWAY);
+ private static final String RAW_MODEL = "model";
+ private static final String RAW_ID = "ID";
+ private static final String RAW_MAC = "MAC";
+ private static final String RAW_IP = "IP";
+ private static final String RAW_ADMIN_URL = "admin_url";
+ private static final String RAW_VENDOR = "vendor";
+ private static final String RAW_VERSION = "version";
+ private static final String[] KEYS = new String[] { RAW_MODEL, RAW_ID, RAW_MAC, RAW_IP, RAW_ADMIN_URL, RAW_VENDOR,
+ RAW_VERSION };
+
+ private static final String TEXT_CHARSET = StandardCharsets.UTF_8.name();
+
+ protected final ThingRegistry thingRegistry;
+ private final Logger logger = LoggerFactory.getLogger(LinkTapBridgeDiscoveryService.class);
+ private final TranslationProvider translationProvider;
+ private final LocaleProvider localeProvider;
+ private final Bundle bundle;
+
+ @Activate
+ public LinkTapBridgeDiscoveryService(final @Reference ThingRegistry thingRegistry,
+ @Reference TranslationProvider translationProvider, @Reference LocaleProvider localeProvider) {
+ this.thingRegistry = thingRegistry;
+ this.translationProvider = translationProvider;
+ this.localeProvider = localeProvider;
+ this.bundle = FrameworkUtil.getBundle(getClass());
+ }
+
+ public String getLocalizedText(String key, @Nullable Object @Nullable... arguments) {
+ String result = translationProvider.getText(bundle, key, key, localeProvider.getLocale(), arguments);
+ return Objects.nonNull(result) ? result : key;
+ }
+
+ @Override
+ public Set<ThingTypeUID> getSupportedThingTypeUIDs() {
+ return SUPPORTED_THING_TYPES;
+ }
+
+ @Override
+ public String getServiceType() {
+ return SERVICE_TYPE;
+ }
+
+ @Override
+ public @Nullable DiscoveryResult createResult(ServiceInfo service) {
+ final String itemId = String.format("%04X", new Random().nextInt(Short.MAX_VALUE));
+ String qualifiedName = service.getQualifiedName();
+ String name = service.getName();
+
+ if (logger.isEnabledForLevel(Level.TRACE)) {
+ logger.trace("[{}] Device found: {}", itemId, cleanPrintableChars(qualifiedName));
+ }
+
+ if (!name.startsWith("LinkTapGw_")) {
+ logger.trace("[{}] Not a LinkTap Gateway - wrong name", itemId);
+ return null;
+ }
+ if (80 != service.getPort()) {
+ logger.trace("[{}] Not a LinkTap Gateway - incorrect port", itemId);
+ return null;
+ }
+
+ if (!"tcp".equals(service.getProtocol())) {
+ logger.trace("[{}] Not a LinkTap Gateway - incorrect protocol", itemId);
+ return null;
+ }
+
+ if (!"http".equals(service.getApplication())) {
+ logger.trace("[{}] Not a LinkTap Gateway - incorrect application", itemId);
+ return null;
+ }
+
+ ThingUID uid = getThingUID(service);
+ if (uid == null) {
+ return null;
+ }
+
+ Properties rawDataProps = extractProps(service);
+ if (rawDataProps.isEmpty()) {
+ return null;
+ }
+
+ final Map<String, Object> bridgeProperties = new HashMap<>(4);
+ final String gatewayId = getGwId(service.getName());
+ bridgeProperties.put(BRIDGE_PROP_GW_ID, gatewayId);
+ final String macId = (String) rawDataProps.get(RAW_MAC);
+ if (macId != null) {
+ bridgeProperties.put(BRIDGE_PROP_MAC_ADDR, macId);
+ }
+ final String version = (String) rawDataProps.get(RAW_VERSION);
+ if (version != null) {
+ bridgeProperties.put(BRIDGE_PROP_GW_VER, version);
+ }
+ final String hostname = getHostName(service);
+ if (hostname.isEmpty()) {
+ return null;
+ }
+ bridgeProperties.put(BRIDGE_CONFIG_HOSTNAME, qualifiedName);
+
+ if (gatewayId.isEmpty()) {
+ return null;
+ }
+ logger.debug("[{}] Discovered Gateway Id {}", itemId, gatewayId);
+
+ final String ipV4Addr = (String) rawDataProps.get(RAW_IP);
+
+ MDNS_LOOKUP.clearItem(qualifiedName);
+ MDNS_LOOKUP.registerItem(qualifiedName, ipV4Addr, () -> {
+ logger.debug("[{}] Registered mdns qualified name to IPv4 {} -> {}", itemId, qualifiedName, ipV4Addr);
+ List<Thing> things = thingRegistry.getAll().stream()
+ .filter(thing -> THING_TYPE_GATEWAY.equals(thing.getThingTypeUID())).toList();
+ for (final Thing thing : things) {
+ final ThingHandler handler = thing.getHandler();
+ if (handler instanceof LinkTapBridgeHandler bridgeHandler) {
+ bridgeHandler.attemptReconnectIfNeeded();
+ logger.trace("[{}] Bridge handler {} notified", itemId, handler.getThing().getLabel());
+ }
+ }
+ });
+
+ return DiscoveryResultBuilder.create((new ThingUID(THING_TYPE_GATEWAY, gatewayId)))
+ .withProperties(bridgeProperties).withLabel("LinkTap Gateway (" + gatewayId + ")")
+ .withRepresentationProperty(BRIDGE_PROP_GW_ID).build();
+ }
+
+ @Override
+ public @Nullable ThingUID getThingUID(ServiceInfo service) {
+ final Map<String, Object> bridgeProperties = new HashMap<>(4);
+ final String gatewayId = getGwId(service.getName());
+ bridgeProperties.put(BRIDGE_PROP_GW_ID, gatewayId);
+ if (bridgeProperties.get(BRIDGE_PROP_GW_ID) == null) {
+ return null;
+ }
+ return (new ThingUID(THING_TYPE_GATEWAY,
+ gatewayId + "_" + String.format("0x%08X", new Random().nextInt(Integer.MAX_VALUE))));
+ }
+
+ public Properties extractProps(ServiceInfo serviceInfo) {
+ final Properties result = new Properties();
+ String data = "";
+ try {
+ data = new String(serviceInfo.getTextBytes(), TEXT_CHARSET);
+ } catch (UnsupportedEncodingException uee) {
+ logger.warn("{}", getLocalizedText("warning.discovery-charset-missing"));
+ }
+ final int[] keyIndexes = new int[7];
+
+ for (int i = 0; i < KEYS.length; ++i) {
+ keyIndexes[i] = data.indexOf(KEYS[i] + "=");
+ }
+ Arrays.sort(keyIndexes);
+ if (keyIndexes[0] == -1) {
+ return result;
+ }
+
+ String wCopy = data;
+ for (int si = keyIndexes.length - 1; si > -1; --si) {
+ final String foundField = wCopy.substring(keyIndexes[si]).trim();
+ wCopy = wCopy.substring(0, keyIndexes[si]);
+ final Optional<String> potentialKey = Arrays.stream(KEYS).filter(foundField::startsWith).findFirst();
+ if (potentialKey.isPresent()) {
+ final String key = potentialKey.get();
+ result.put(key, foundField.substring(key.length() + 1));
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public long getRemovalGracePeriodSeconds(ServiceInfo serviceInfo) {
+ return MDNSDiscoveryParticipant.super.getRemovalGracePeriodSeconds(serviceInfo);
+ }
+
+ private String getGwId(final String serviceName) {
+ String[] segments = serviceName.split("_");
+ if (segments.length > 1) {
+ return segments[1];
+ }
+ return TLGatewayFrame.EMPTY_STRING;
+ }
+
+ private String getHostName(final ServiceInfo serviceInfo) {
+ final Inet4Address[] addrs = serviceInfo.getInet4Addresses();
+ if (addrs.length == 0) {
+ logger.trace("No IPv4 given in mdns data");
+ return TLGatewayFrame.EMPTY_STRING;
+ }
+ String candidateDnsName = addrs[0].getHostName();
+ if (candidateDnsName.isEmpty()) {
+ logger.trace("No DNS given by IPv4 address from mdns data");
+ candidateDnsName = addrs[0].toString();
+ }
+ if (candidateDnsName.startsWith("/")) {
+ candidateDnsName = candidateDnsName.substring(1);
+ }
+ return candidateDnsName;
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.internal;
+
+import static org.openhab.binding.linktap.internal.LinkTapBindingConstants.*;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.*;
+import static org.openhab.binding.linktap.protocol.frames.ValidationError.Cause.BUG;
+import static org.openhab.binding.linktap.protocol.frames.ValidationError.Cause.USER;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.UnknownHostException;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import javax.validation.constraints.NotNull;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.linktap.configuration.LinkTapBridgeConfiguration;
+import org.openhab.binding.linktap.protocol.frames.GatewayConfigResp;
+import org.openhab.binding.linktap.protocol.frames.GatewayDeviceResponse;
+import org.openhab.binding.linktap.protocol.frames.TLGatewayFrame;
+import org.openhab.binding.linktap.protocol.frames.ValidationError;
+import org.openhab.binding.linktap.protocol.http.CommandNotSupportedException;
+import org.openhab.binding.linktap.protocol.http.DeviceIdException;
+import org.openhab.binding.linktap.protocol.http.GatewayIdException;
+import org.openhab.binding.linktap.protocol.http.InvalidParameterException;
+import org.openhab.binding.linktap.protocol.http.LinkTapException;
+import org.openhab.binding.linktap.protocol.http.NotTapLinkGatewayException;
+import org.openhab.binding.linktap.protocol.http.TransientCommunicationIssueException;
+import org.openhab.binding.linktap.protocol.http.WebServerApi;
+import org.openhab.binding.linktap.protocol.servers.BindingServlet;
+import org.openhab.binding.linktap.protocol.servers.IHttpClientProvider;
+import org.openhab.core.cache.ExpiringCache;
+import org.openhab.core.config.discovery.DiscoveryServiceRegistry;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.i18n.TranslationProvider;
+import org.openhab.core.thing.Bridge;
+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.ThingUID;
+import org.openhab.core.thing.binding.BaseBridgeHandler;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.openhab.core.types.Command;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.FrameworkUtil;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link LinkTapBridgeHandler} class defines the handler for a LinkTapHandler
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class LinkTapBridgeHandler extends BaseBridgeHandler {
+
+ public static final LookupWrapper<@Nullable LinkTapBridgeHandler> ADDR_LOOKUP = new LookupWrapper<>();
+ public static final LookupWrapper<@Nullable LinkTapBridgeHandler> GW_ID_LOOKUP = new LookupWrapper<>();
+ public static final LookupWrapper<@Nullable LinkTapHandler> DEV_ID_LOOKUP = new LookupWrapper<>();
+ public static final LookupWrapper<@Nullable String> MDNS_LOOKUP = new LookupWrapper<>();
+ private static final long MIN_TIME_BETWEEN_MDNS_SCANS_MS = 600000;
+
+ private final DiscoveryServiceRegistry discoverySrvReg;
+ private final TranslationProvider translationProvider;
+ private final LocaleProvider localeProvider;
+ private final Bundle bundle;
+ private final Logger logger = LoggerFactory.getLogger(LinkTapBridgeHandler.class);
+ private final Object schedulerLock = new Object();
+ private final Object reconnectFutureLock = new Object();
+ private final Object getConfigLock = new Object();
+
+ private volatile String currentGwId = "";
+ private volatile LinkTapBridgeConfiguration config = new LinkTapBridgeConfiguration();
+ private volatile long lastGwCommandRecvTs = 0L;
+ private volatile long lastMdnsScanMillis = -1L;
+
+ private String bridgeKey = "";
+ private IHttpClientProvider httpClientProvider;
+ private @Nullable ScheduledFuture<?> backgroundGwPollingScheduler;
+ private @Nullable ScheduledFuture<?> connectRepair = null;
+
+ protected ExpiringCache<String> lastGetConfigCache = new ExpiringCache<>(Duration.ofSeconds(10),
+ LinkTapBridgeHandler::expireCacheContents);
+
+ private static @Nullable String expireCacheContents() {
+ return null;
+ }
+
+ @Activate
+ public LinkTapBridgeHandler(final Bridge bridge, IHttpClientProvider httpClientProvider,
+ @Reference DiscoveryServiceRegistry discoveryService, @Reference TranslationProvider translationProvider,
+ @Reference LocaleProvider localeProvider) {
+ super(bridge);
+ this.httpClientProvider = httpClientProvider;
+ this.discoverySrvReg = discoveryService;
+ this.translationProvider = translationProvider;
+ this.localeProvider = localeProvider;
+ this.bundle = FrameworkUtil.getBundle(getClass());
+ TransactionProcessor.getInstance().setTranslationProviderInfo(translationProvider, localeProvider, bundle);
+ WebServerApi.getInstance().setTranslationProviderInfo(translationProvider, localeProvider, bundle);
+ BindingServlet.getInstance().setTranslationProviderInfo(translationProvider, localeProvider, bundle);
+ }
+
+ public String getLocalizedText(String key, @Nullable Object @Nullable... arguments) {
+ String result = translationProvider.getText(bundle, key, key, localeProvider.getLocale(), arguments);
+ return Objects.nonNull(result) ? result : key;
+ }
+
+ private void startGwPolling() {
+ synchronized (schedulerLock) {
+ cancelGwPolling();
+ backgroundGwPollingScheduler = scheduler.scheduleWithFixedDelay(() -> {
+ if (lastGwCommandRecvTs + 120000 < System.currentTimeMillis()) {
+ getGatewayConfiguration();
+ }
+ }, 5000, 120000, TimeUnit.MILLISECONDS);
+ }
+ }
+
+ private void cancelGwPolling() {
+ synchronized (schedulerLock) {
+ final ScheduledFuture<?> ref = backgroundGwPollingScheduler;
+ if (ref != null) {
+ ref.cancel(true);
+ backgroundGwPollingScheduler = null;
+ }
+ }
+ }
+
+ private void requestMdnsScan() {
+ final long sysMillis = System.currentTimeMillis();
+ if (lastMdnsScanMillis + MIN_TIME_BETWEEN_MDNS_SCANS_MS < sysMillis) {
+ logger.debug("Requesting MDNS Scan");
+ discoverySrvReg.startScan(THING_TYPE_GATEWAY, null);
+ lastMdnsScanMillis = sysMillis;
+ } else {
+ logger.trace("Not requesting MDNS Scan last ran under 10 min's ago");
+ }
+ }
+
+ @Override
+ public void initialize() {
+ updateStatus(ThingStatus.UNKNOWN);
+ config = getConfigAs(LinkTapBridgeConfiguration.class);
+ scheduleReconnect(0);
+ }
+
+ @Override
+ public void dispose() {
+ cancelReconnect();
+ cancelGwPolling();
+ deregisterBridge(this);
+ GW_ID_LOOKUP.deregisterItem(currentGwId, this, () -> {
+ });
+ }
+
+ @Override
+ public Collection<Class<? extends ThingHandlerService>> getServices() {
+ return Set.of(LinkTapDeviceDiscoveryService.class);
+ }
+
+ public @Nullable String getGatewayId() {
+ return currentGwId;
+ }
+
+ private void deregisterBridge(final LinkTapBridgeHandler ref) {
+ if (!bridgeKey.isEmpty()) {
+ ADDR_LOOKUP.deregisterItem(bridgeKey, ref, () -> {
+ BindingServlet.getInstance().unregisterServlet();
+ });
+ bridgeKey = "";
+ }
+ }
+
+ private boolean registerBridge(final LinkTapBridgeHandler ref) {
+ final WebServerApi api = WebServerApi.getInstance();
+ api.setHttpClient(httpClientProvider.getHttpClient());
+ try {
+ final String host = getHostname();
+
+ if (!bridgeKey.equals(host)) {
+ deregisterBridge(this);
+ bridgeKey = host;
+ }
+
+ if (!ADDR_LOOKUP.registerItem(bridgeKey, this, () -> {
+ BindingServlet.getInstance().registerServlet();
+ })) {
+ return false;
+ }
+ } catch (UnknownHostException e) {
+ deregisterBridge(this);
+ return false;
+ }
+ return true;
+ }
+
+ public void getGatewayConfiguration() {
+ String resp = "";
+ synchronized (getConfigLock) {
+ resp = lastGetConfigCache.getValue();
+ if (lastGetConfigCache.isExpired() || resp == null || resp.isBlank()) {
+ TLGatewayFrame req = new TLGatewayFrame(CMD_GET_CONFIGURATION);
+ resp = sendApiRequest(req);
+ GatewayDeviceResponse respFrame = LinkTapBindingConstants.GSON.fromJson(resp,
+ GatewayDeviceResponse.class);
+ // The system may not have picked up the ID before in which case - extract it from the error response
+ // and re-run the request to ensure a full configuration data-set is retrieved.
+ // This is normally populated as part of the sendApiRequest sequencing where the gateway id is
+ // auto-added,
+ // if available.
+ if (req.gatewayId.isEmpty() && respFrame != null
+ && respFrame.getRes() == GatewayDeviceResponse.ResultStatus.RET_GATEWAY_ID_NOT_MATCHED) {
+ // Use the response GW_ID from the error response - to re-request with the correct ID
+ // This only happens in occasional startup race conditions, but this removes a low change
+ // bug being hit.
+ req.gatewayId = respFrame.gatewayId;
+ resp = sendApiRequest(req);
+ }
+ lastGetConfigCache.putValue(resp);
+ }
+
+ }
+
+ final GatewayConfigResp gwConfig = LinkTapBindingConstants.GSON.fromJson(resp, GatewayConfigResp.class);
+ if (gwConfig == null) {
+ return;
+ }
+ currentGwId = gwConfig.gatewayId;
+
+ final String version = gwConfig.version;
+ final String volUnit = gwConfig.volumeUnit;
+ final String[] devIds = gwConfig.endDevices;
+ final String[] devNames = gwConfig.deviceNames;
+ final Integer utcOffset = gwConfig.utfOfs;
+ if (!version.equals(editProperties().get(BRIDGE_PROP_GW_VER))) {
+ final Map<String, String> props = editProperties();
+ props.put(BRIDGE_PROP_GW_VER, version);
+ updateProperties(props);
+ return;
+ }
+ if (!volUnit.equals(editProperties().get(BRIDGE_PROP_VOL_UNIT))) {
+ final Map<String, String> props = editProperties();
+ props.put(BRIDGE_PROP_VOL_UNIT, volUnit);
+ updateProperties(props);
+ }
+ if (utcOffset != DEFAULT_INT) { // This is only in later firmwares
+ final String strVal = String.valueOf(utcOffset);
+ if (!strVal.equals(editProperties().get(BRIDGE_PROP_UTC_OFFSET))) {
+ final Map<String, String> props = editProperties();
+ props.put(BRIDGE_PROP_UTC_OFFSET, strVal);
+ updateProperties(props);
+ }
+ }
+
+ boolean updatedDeviceInfo = devIds.length != discoveredDevices.size();
+
+ for (int i = 0; i < devIds.length; ++i) {
+ LinkTapDeviceMetadata deviceInfo = new LinkTapDeviceMetadata(devIds[i], devNames[i]);
+ LinkTapDeviceMetadata replaced = discoveredDevices.put(deviceInfo.deviceId, deviceInfo);
+ if (replaced != null
+ && (!replaced.deviceId.equals(devIds[i]) || !replaced.deviceName.equals(devNames[i]))) {
+ updatedDeviceInfo = true;
+ }
+ }
+
+ handlers.forEach(x -> x.handleMetadataRetrieved(this));
+
+ if (updatedDeviceInfo) {
+ this.scheduler.execute(() -> {
+ for (Thing el : getThing().getThings()) {
+ final ThingHandler th = el.getHandler();
+ if (th instanceof IBridgeData bridgeData) {
+ bridgeData.handleBridgeDataUpdated();
+ }
+ }
+ });
+ }
+ }
+
+ public String sendApiRequest(final TLGatewayFrame req) {
+ final UUID uid = UUID.randomUUID();
+
+ final WebServerApi api = WebServerApi.getInstance();
+ String host = "Unresolved";
+ try {
+ host = getHostname();
+ final boolean confirmGateway = req.command != TLGatewayFrame.CMD_GET_CONFIGURATION;
+ if (confirmGateway && (host.isEmpty() || currentGwId.isEmpty())) {
+ logger.warn("{}", getLocalizedText("warning.host-gw-unknown-for-cmd", host, currentGwId, req.command));
+ return "";
+ }
+ if (req.gatewayId.isEmpty()) {
+ req.gatewayId = currentGwId;
+ }
+ final String reqData = LinkTapBindingConstants.GSON.toJson(req);
+ logger.debug("{} = APP BRIDGE -> GW -> Request {}", uid, reqData);
+ final String respData = api.sendRequest(host, reqData);
+ logger.debug("{} = APP BRIDGE -> GW -> Response {}", uid, respData);
+ final TLGatewayFrame gwResponseFrame = LinkTapBindingConstants.GSON.fromJson(respData,
+ TLGatewayFrame.class);
+ if (confirmGateway && gwResponseFrame != null && !gwResponseFrame.gatewayId.equals(req.gatewayId)) {
+ logger.warn("{}", getLocalizedText("warning.response-from-wrong-gw-id", uid, req.gatewayId,
+ gwResponseFrame.gatewayId));
+ return "";
+ }
+ if (gwResponseFrame != null && req.command != gwResponseFrame.command) {
+ logger.warn("{}",
+ getLocalizedText("warning.incorrect-cmd-resp", uid, req.command, gwResponseFrame.command));
+ return "";
+ }
+ return respData;
+ } catch (NotTapLinkGatewayException e) {
+ logger.warn("{}", getLocalizedText("warning.not-taplink-gw", uid, host));
+ } catch (UnknownHostException e) {
+ logger.warn("{}", getLocalizedText("warning.comms-issue-auto-retry", uid, e.getMessage()));
+ scheduleReconnect();
+ } catch (TransientCommunicationIssueException e) {
+ logger.warn("{}", getLocalizedText("warning.comms-issue-auto-retry", uid, getLocalizedText(e.getI18Key())));
+ scheduleReconnect();
+ }
+ return "";
+ }
+
+ private void connect() {
+ // Check if we can resolve the remote host, if so then it can be mapped back to a bridge handler.
+ // If not further communications would fail - so it's offline.
+ if (!registerBridge(this)) {
+ requestMdnsScan();
+ scheduleReconnect();
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ getLocalizedText("bridge.error.host-not-found"));
+ return;
+ }
+
+ final WebServerApi api = WebServerApi.getInstance();
+ api.setHttpClient(httpClientProvider.getHttpClient());
+ try {
+ final Map<String, String> bridgeProps = api.getBridgeProperities(bridgeKey);
+ if (!bridgeProps.isEmpty()) {
+ final String readGwId = bridgeProps.get(BRIDGE_PROP_GW_ID);
+ if (readGwId != null) {
+ currentGwId = readGwId;
+ }
+ final Map<String, String> currentProps = editProperties();
+ currentProps.putAll(bridgeProps);
+ updateProperties(currentProps);
+ } else {
+ if (!api.unlockWebInterface(bridgeKey, config.username, config.password)) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ getLocalizedText("bridge.error.check-credentials"));
+ return;
+ }
+ }
+
+ getGatewayConfiguration();
+
+ // Update the GW ID -> this bridge lookup
+ GW_ID_LOOKUP.registerItem(currentGwId, this, () -> {
+ });
+
+ if (Thread.currentThread().isInterrupted()) {
+ return;
+ }
+
+ @NotNull
+ final String hostname = getHostname(config);
+
+ String localServerAddr = "";
+ try (Socket socket = new Socket()) {
+ try {
+ socket.connect(new InetSocketAddress(hostname, 80), 1500);
+ } catch (IOException e) {
+ logger.warn("{}", getLocalizedText("warning.failed-local-address-detection", e.getMessage()));
+ throw new TransientCommunicationIssueException("Local address lookup failure",
+ "exception.local-addr-lookup-failure");
+ }
+ localServerAddr = socket.getLocalAddress().getHostAddress();
+ logger.trace("Local address for connectivity is {}", socket.getLocalAddress().getHostAddress());
+ } catch (IOException e) {
+ logger.trace("Failed to connect to remote device due to exception", e);
+ }
+
+ final String servletEp = BindingServlet.getServletAddress(localServerAddr,
+ getLocalizedText("warning.no-http-server-port"));
+ final Optional<String> servletEpOpt = (!servletEp.isEmpty()) ? Optional.of(servletEp) : Optional.empty();
+ api.configureBridge(hostname, Optional.of(config.enableMDNS), Optional.of(config.enableJSONComms),
+ servletEpOpt);
+ updateStatus(ThingStatus.ONLINE);
+ if (Thread.currentThread().isInterrupted()) {
+ return;
+ }
+ startGwPolling();
+ connectRepair = null;
+
+ final Firmware firmware = new Firmware(getThing().getProperties().get(BRIDGE_PROP_GW_VER));
+ if (!firmware.supportsLocalConfig()) {
+ logger.warn("{}", getLocalizedText("warning.fw-update-local-config", getThing().getLabel(),
+ firmware.getRecommendedMinVer()));
+ }
+ } catch (InterruptedException ignored) {
+ } catch (LinkTapException | NotTapLinkGatewayException e) {
+ deregisterBridge(this);
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ getLocalizedText("bridge.error.target-is-not-gateway"));
+ } catch (TransientCommunicationIssueException e) {
+ scheduleReconnect();
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ getLocalizedText("bridge.error.cannot-connect"));
+ } catch (UnknownHostException e) {
+ scheduleReconnect();
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ getLocalizedText("bridge.error.unknown-host"));
+ }
+ }
+
+ private void scheduleReconnect() {
+ scheduleReconnect(15);
+ }
+
+ public void attemptReconnectIfNeeded() {
+ if (ThingStatus.OFFLINE.equals(getThing().getStatus())) {
+ synchronized (reconnectFutureLock) {
+ if (connectRepair != null) {
+ scheduleReconnect(0);
+ }
+ }
+ }
+ }
+
+ private void scheduleReconnect(int seconds) {
+ if (seconds < 1) {
+ seconds = 1;
+ }
+ logger.trace("Scheduling connection re-attempt in {} seconds", seconds);
+ synchronized (reconnectFutureLock) {
+ cancelReconnect();
+ connectRepair = scheduler.schedule(this::connect, seconds, TimeUnit.SECONDS); // Schedule a retry
+ }
+ }
+
+ private void cancelReconnect() {
+ synchronized (reconnectFutureLock) {
+ final @Nullable ScheduledFuture<?> ref = connectRepair;
+ if (ref != null) {
+ ref.cancel(true);
+ connectRepair = null;
+ }
+ }
+ }
+
+ @Override
+ public void handleCommand(final ChannelUID channelUID, final Command command) {
+ }
+
+ protected @NotNull String getHostname() throws UnknownHostException {
+ return getHostname(config);
+ }
+
+ private @NotNull String getHostname(final LinkTapBridgeConfiguration config) throws UnknownHostException {
+ @NotNull
+ String hostname = config.host;
+ final String mdnsLookup = MDNS_LOOKUP.getItem(hostname);
+ if (mdnsLookup != null) {
+ hostname = mdnsLookup;
+ }
+ return InetAddress.getByName(hostname).getHostAddress();
+ }
+
+ private final Object singleCommLock = new Object();
+
+ public String sendRequest(final TLGatewayFrame frame) throws DeviceIdException, InvalidParameterException {
+ // Validate the payload is within the expected limits for the device its being sent to
+ if (config.enforceProtocolLimits) {
+ final Collection<ValidationError> errors = frame.getValidationErrors();
+ if (!errors.isEmpty()) {
+ final String bugs = errors.stream().filter(x -> x.getCause() == BUG).map(ValidationError::toString)
+ .collect(Collectors.joining(","));
+ final String userDataIssues = errors.stream().filter(x -> x.getCause() == USER)
+ .map(ValidationError::toString).collect(Collectors.joining(","));
+ if (!bugs.isEmpty()) {
+ logger.warn("{}",
+ getLocalizedText("bug-report.unexpected-payload-failure", getThing().getLabel(), bugs));
+ }
+ if (!userDataIssues.isEmpty()) {
+ logger.warn("{}", getLocalizedText("warning.user-data-payload-failure", getThing().getLabel(),
+ userDataIssues));
+ }
+ return "";
+ }
+ }
+ final TransactionProcessor tp = TransactionProcessor.getInstance();
+ final String gatewayId = getGatewayId();
+ if (gatewayId == null) {
+ logger.warn("{}", getLocalizedText("warning.error-with-gw-id"));
+ return "";
+ }
+ frame.gatewayId = gatewayId;
+ // The gateway is a single device that may have to do RF, limit the comm's to ensure
+ // it can maintain a good QoS. Responses for most commands are very fast on a reasonable network.
+ try {
+ synchronized (singleCommLock) {
+ try {
+ return tp.sendRequest(this, frame);
+ } catch (final CommandNotSupportedException cnse) {
+ logger.warn("{}",
+ getLocalizedText("warning.device-no-accept", getThing().getLabel(), cnse.getMessage()));
+ }
+ }
+ } catch (final GatewayIdException gide) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, gide.getI18Key());
+ }
+ return "";
+ }
+
+ public ThingUID getUID() {
+ return thing.getUID();
+ }
+
+ /**
+ * Discovery handling of Gateway owned Devices
+ */
+
+ public void registerMetaDataUpdatedHandler(DeviceMetaDataUpdatedHandler dmduh) {
+ handlers.add(dmduh);
+ }
+
+ public void unregisterMetaDataUpdatedHandler(DeviceMetaDataUpdatedHandler dmduh) {
+ handlers.remove(dmduh);
+ }
+
+ private final CopyOnWriteArrayList<DeviceMetaDataUpdatedHandler> handlers = new CopyOnWriteArrayList<>();
+
+ private ConcurrentMap<String, LinkTapDeviceMetadata> discoveredDevices = new ConcurrentHashMap<>();
+
+ public final Stream<LinkTapDeviceMetadata> getDiscoveredDevices() {
+ return discoveredDevices.values().stream();
+ }
+
+ public final Map<String, LinkTapDeviceMetadata> getDeviceLookup() {
+ return discoveredDevices;
+ }
+
+ public void processGatewayCommand(final int commandId, final String frame) {
+ logger.debug("{} processing gateway request with command {}", this.getThing().getLabel(), commandId);
+ // Store this so that the only when necessary can polls be done - aka
+ // no direct from Gateway communications.
+ lastGwCommandRecvTs = System.currentTimeMillis();
+ switch (commandId) {
+ case CMD_HANDSHAKE:
+ lastGetConfigCache.invalidateValue();
+ processCommand0(frame);
+ break;
+ case CMD_RAINFALL_DATA:
+ case CMD_NOTIFICATION_WATERING_SKIPPED:
+ case CMD_DATETIME_SYNC:
+ logger.debug("No implementation for command {} for processing the GW request", commandId);
+ }
+ }
+
+ private void processCommand0(final String request) {
+ final GatewayConfigResp decoded = LinkTapBindingConstants.GSON.fromJson(request, GatewayConfigResp.class);
+
+ // Check the current version property matches and if not update it
+ final String currentVerKnown = editProperties().get(BRIDGE_PROP_GW_VER);
+ if (decoded != null && currentVerKnown != null && !decoded.version.isEmpty()) {
+ if (!currentVerKnown.equals(decoded.version)) {
+ final Map<String, String> currentProps = editProperties();
+ currentProps.put(BRIDGE_PROP_GW_VER, decoded.version);
+ updateProperties(currentProps);
+ }
+ }
+ final String currentVolUnit = editProperties().get(BRIDGE_PROP_VOL_UNIT);
+ if (decoded != null && currentVolUnit != null && !decoded.volumeUnit.isEmpty()) {
+ if (!currentVolUnit.equals(decoded.volumeUnit)) {
+ final Map<String, String> currentProps = editProperties();
+ currentProps.put(BRIDGE_PROP_VOL_UNIT, decoded.volumeUnit);
+ updateProperties(currentProps);
+ }
+ }
+ final String[] devices = decoded != null ? decoded.endDevices : EMPTY_STRING_ARRAY;
+ // Go through all the device ID's returned check we know about them.
+ // If not a background scan should be done
+ boolean fullScanRequired = false;
+ if (discoveredDevices.size() != devices.length) {
+ fullScanRequired = true;
+ }
+ if (!discoveredDevices.keySet().containsAll(Arrays.stream(devices).toList())) {
+ fullScanRequired = true;
+ }
+ if (fullScanRequired) {
+ logger.trace("The configured devices have changed a full scan should be run");
+ scheduler.execute(this::getGatewayConfiguration);
+ }
+ }
+
+ @Override
+ public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) {
+ scheduler.execute(this::getGatewayConfiguration);
+ super.childHandlerDisposed(childHandler, childThing);
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.internal;
+
+import static org.openhab.binding.linktap.internal.LinkTapBindingConstants.*;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.config.discovery.AbstractThingHandlerDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.ServiceScope;
+
+/**
+ * The {@link LinkTapDeviceDiscoveryService} is an implementation of a discovery service for VeSync devices. The
+ * meta-data is
+ * read by the bridge, and the discovery data updated via a callback implemented by the DeviceMetaDataUpdatedHandler.
+ *
+ * @author David Godyear - Initial contribution
+ */
+@NonNullByDefault
+@Component(scope = ServiceScope.PROTOTYPE, service = LinkTapDeviceDiscoveryService.class, configurationPid = "discovery.linktap.devices")
+public class LinkTapDeviceDiscoveryService extends AbstractThingHandlerDiscoveryService<LinkTapBridgeHandler>
+ implements DeviceMetaDataUpdatedHandler {
+
+ private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_GATEWAY);
+ private static final int DISCOVER_TIMEOUT_SECONDS = 5;
+
+ private @NonNullByDefault({}) ThingUID bridgeUID;
+
+ /**
+ * Creates a VeSyncDiscoveryService with enabled autostart.
+ */
+ public LinkTapDeviceDiscoveryService() {
+ super(LinkTapBridgeHandler.class, SUPPORTED_THING_TYPES, DISCOVER_TIMEOUT_SECONDS);
+ }
+
+ @Override
+ public Set<ThingTypeUID> getSupportedThingTypes() {
+ return SUPPORTED_THING_TYPES;
+ }
+
+ @Override
+ public void activate() {
+ final Map<String, Object> properties = new HashMap<>();
+ properties.put(DiscoveryService.CONFIG_PROPERTY_BACKGROUND_DISCOVERY, Boolean.TRUE);
+ super.activate(properties);
+ }
+
+ @Override
+ public void initialize() {
+ bridgeUID = thingHandler.getUID();
+ super.initialize();
+ }
+
+ @Override
+ protected void startBackgroundDiscovery() {
+ thingHandler.registerMetaDataUpdatedHandler(this);
+ }
+
+ @Override
+ protected void stopBackgroundDiscovery() {
+ thingHandler.unregisterMetaDataUpdatedHandler(this);
+ }
+
+ @Override
+ protected void startScan() {
+ // If the bridge is not online no other thing devices can be found, so no reason to scan at this moment.
+ removeOlderResults(getTimestampOfLastScan());
+ }
+
+ @Override
+ public void handleMetadataRetrieved(final LinkTapBridgeHandler handler) {
+ thingHandler.getDiscoveredDevices().map(x -> {
+ final Map<String, Object> properties = new HashMap<>(4);
+ properties.put(DEVICE_PROP_DEV_ID, x.deviceId);
+ properties.put(DEVICE_PROP_DEV_NAME, x.deviceName);
+ properties.put(DEVICE_CONFIG_DEV_ID, x.deviceId);
+ properties.put(DEVICE_CONFIG_DEV_NAME, x.deviceName);
+ properties.put(DEVICE_CONFIG_AUTO_ALERTS_ENABLE, true);
+ return DiscoveryResultBuilder.create(new ThingUID(THING_TYPE_DEVICE, bridgeUID, x.deviceId))
+ .withBridge(bridgeUID).withProperties(properties).withLabel(x.deviceName)
+ .withRepresentationProperty(DEVICE_PROP_DEV_ID).build();
+ }).forEach(this::thingDiscovered);
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link LinkTapDeviceMetadata} class contains the definition of a devices metadata as given by a Gateway device.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class LinkTapDeviceMetadata {
+
+ /**
+ * The ID of the device as stored in the relevant Gateway Device.
+ */
+ public final String deviceId;
+
+ /**
+ * The human-readable name of the device as stored in the relevant Gateway Device.
+ */
+ public final String deviceName;
+
+ public LinkTapDeviceMetadata(final String deviceId, final String deviceName) {
+ this.deviceId = deviceId;
+ this.deviceName = deviceName;
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.internal;
+
+import static org.openhab.binding.linktap.internal.LinkTapBindingConstants.*;
+import static org.openhab.binding.linktap.protocol.frames.DismissAlertReq.*;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.linktap.protocol.frames.AlertStateReq;
+import org.openhab.binding.linktap.protocol.frames.DeviceCmdReq;
+import org.openhab.binding.linktap.protocol.frames.DismissAlertReq;
+import org.openhab.binding.linktap.protocol.frames.EndpointDeviceResponse;
+import org.openhab.binding.linktap.protocol.frames.LockReq;
+import org.openhab.binding.linktap.protocol.frames.PauseWateringPlanReq;
+import org.openhab.binding.linktap.protocol.frames.StartWateringReq;
+import org.openhab.binding.linktap.protocol.frames.WaterMeterStatus;
+import org.openhab.binding.linktap.protocol.http.InvalidParameterException;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.i18n.TranslationProvider;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.unit.ImperialUnits;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.storage.Storage;
+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.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link LinkTapHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class LinkTapHandler extends PollingDeviceHandler {
+
+ private static final String DEFAULT_INST_WATERING_VOL_LIMIT = "0";
+ private static final String DEFAULT_INST_WATERING_TIME_LIMIT = "15";
+ private static final List<String> READBACK_DISABLED_CHANNELS = List.of(DEVICE_CHANNEL_OH_VOLUME_LIMIT,
+ DEVICE_CHANNEL_OH_DURATION_LIMIT);
+
+ private final Logger logger = LoggerFactory.getLogger(LinkTapHandler.class);
+ private final Storage<String> strStore;
+ private final Object pausePlanLock = new Object();
+
+ private volatile boolean pausePlanActive = false;
+
+ private @Nullable ScheduledFuture<?> pausePlanFuture = null;
+
+ public LinkTapHandler(Thing thing, Storage<String> strStore, TranslationProvider translationProvider,
+ LocaleProvider localeProvider) {
+ super(thing, translationProvider, localeProvider);
+ this.strStore = strStore;
+ }
+
+ /**
+ * Abstract method implementations from PollingDeviceHandler
+ * required for the lifecycle of LinkTap devices.
+ */
+
+ @Override
+ protected void runStartInit() {
+ try {
+ if (config.enableAlerts) {
+ sendRequest(new AlertStateReq(0, true));
+ }
+
+ final String[] chansToRefresh = new String[] { DEVICE_CHANNEL_PAUSE_PLAN_OVERRIDE,
+ DEVICE_CHANNEL_PAUSE_PLAN_EXPIRES, DEVICE_CHANNEL_OH_DURATION_LIMIT,
+ DEVICE_CHANNEL_OH_VOLUME_LIMIT };
+ for (String chanId : chansToRefresh) {
+ final Channel pausePlanChan = getThing().getChannel(chanId);
+ if (pausePlanChan != null) {
+ handleCommand(pausePlanChan.getUID(), RefreshType.REFRESH);
+ }
+ }
+
+ final String pausePlanState = strStore.get(DEVICE_CHANNEL_PAUSE_PLAN_OVERRIDE);
+ if (OnOffType.ON.toString().equals(pausePlanState)) {
+ scheduleRenewPlanPause();
+ }
+
+ } catch (final InvalidParameterException ipe) {
+ logger.warn("{}", getLocalizedText("bug-report.failed-alert-enable", getThing().getLabel()));
+ }
+ }
+
+ @Override
+ protected void registerDevice() {
+ LinkTapBridgeHandler.DEV_ID_LOOKUP.registerItem(registeredDeviceId, this, () -> {
+ });
+ }
+
+ @Override
+ protected void deregisterDevice() {
+ LinkTapBridgeHandler.DEV_ID_LOOKUP.deregisterItem(registeredDeviceId, this, () -> {
+ });
+ }
+
+ @Override
+ protected String getPollResponseData() {
+ try {
+ return sendRequest(new DeviceCmdReq(CMD_UPDATE_WATER_TIMER_STATUS));
+ } catch (final InvalidParameterException ipe) {
+ logger.warn("{}", getLocalizedText("bug-report.poll-failure", getThing().getLabel()));
+ return "";
+ }
+ }
+
+ @Override
+ protected void processPollResponseData(String data) {
+ processCommand3(data);
+ }
+
+ /**
+ * OpenHab handlers
+ */
+
+ @Override
+ public void dispose() {
+ cancelPlanPauseRenew();
+ super.dispose();
+ }
+
+ private void scheduleRenewPlanPause() {
+ synchronized (pausePlanLock) {
+ cancelPlanPauseRenew();
+ pausePlanFuture = scheduler.scheduleWithFixedDelay(this::requestPlanPause, 0, 55, TimeUnit.MINUTES);
+ pausePlanActive = true;
+ }
+ }
+
+ private boolean isPlanPauseActive() {
+ return pausePlanActive;
+ }
+
+ private void cancelPlanPauseRenew() {
+ synchronized (pausePlanLock) {
+ ScheduledFuture<?> ref = pausePlanFuture;
+ if (ref != null) {
+ ref.cancel(false);
+ pausePlanFuture = null;
+ }
+ pausePlanActive = false;
+ }
+ }
+
+ private void requestPlanPause() {
+ try {
+ final String respRaw = sendRequest(new PauseWateringPlanReq(1.0));
+ final EndpointDeviceResponse devResp = GSON.fromJson(respRaw, EndpointDeviceResponse.class);
+ if (devResp != null && devResp.isSuccess()) {
+ final DateTimeType expiryTime = new DateTimeType(LocalDateTime.now().plusHours(1).toString());
+ strStore.put(DEVICE_CHANNEL_PAUSE_PLAN_EXPIRES, expiryTime.format(null));
+ updateState(DEVICE_CHANNEL_PAUSE_PLAN_EXPIRES, expiryTime);
+ }
+ } catch (final InvalidParameterException ignored) {
+ logger.warn("{}", getLocalizedText("bug-report.pause-plan-failure", getThing().getLabel()));
+ }
+ }
+
+ @Override
+ public void handleCommand(final ChannelUID channelUID, final Command command) {
+ scheduler.submit(() -> {
+ try {
+ if (command instanceof RefreshType) {
+ switch (channelUID.getId()) {
+ case DEVICE_CHANNEL_PAUSE_PLAN_EXPIRES: {
+ final String savedVal = strStore.get(DEVICE_CHANNEL_PAUSE_PLAN_EXPIRES);
+ if (savedVal != null) {
+ updateState(DEVICE_CHANNEL_PAUSE_PLAN_EXPIRES, new DateTimeType(savedVal));
+ }
+ }
+ break;
+ case DEVICE_CHANNEL_OH_DURATION_LIMIT: {
+ final String savedVal = strStore.get(DEVICE_CHANNEL_OH_DURATION_LIMIT);
+ if (savedVal != null) {
+ updateState(DEVICE_CHANNEL_OH_DURATION_LIMIT,
+ new QuantityType<>(Integer.valueOf(savedVal), Units.SECOND));
+ } else {
+ updateState(DEVICE_CHANNEL_OH_DURATION_LIMIT, new QuantityType<>(15, Units.SECOND));
+ }
+ }
+ break;
+ case DEVICE_CHANNEL_OH_VOLUME_LIMIT: {
+ final String savedVal = strStore.get(DEVICE_CHANNEL_OH_VOLUME_LIMIT);
+ if (savedVal != null) {
+ updateState(DEVICE_CHANNEL_OH_VOLUME_LIMIT,
+ new QuantityType<>(Integer.valueOf(savedVal), Units.LITRE));
+ } else {
+ updateState(DEVICE_CHANNEL_OH_VOLUME_LIMIT, new QuantityType<>(10, Units.LITRE));
+ }
+ }
+ break;
+ case DEVICE_CHANNEL_PAUSE_PLAN_OVERRIDE:
+ final String savedVal = strStore.get(DEVICE_CHANNEL_PAUSE_PLAN_OVERRIDE);
+ updateState(DEVICE_CHANNEL_OH_VOLUME_LIMIT,
+ OnOffType.ON.toString().equals(savedVal) ? OnOffType.ON : OnOffType.OFF);
+ break;
+ default:
+ pollForUpdate(false);
+ }
+ } else if (command instanceof QuantityType quantityCommand) {
+ int targetValue = quantityCommand.intValue();
+ switch (channelUID.getId()) {
+ case DEVICE_CHANNEL_OH_DURATION_LIMIT:
+ strStore.put(DEVICE_CHANNEL_OH_DURATION_LIMIT, String.valueOf(targetValue));
+ break;
+ case DEVICE_CHANNEL_OH_VOLUME_LIMIT:
+ strStore.put(DEVICE_CHANNEL_OH_VOLUME_LIMIT, String.valueOf(targetValue));
+ break;
+ }
+ } else if (command instanceof StringType stringCmd) {
+ switch (channelUID.getId()) {
+ case DEVICE_CHANNEL_CHILD_LOCK: {
+ sendRequest(new LockReq(Integer.valueOf(command.toString())));
+ }
+ break;
+ }
+ } else if (command instanceof OnOffType) {
+ // Alert dismiss events below
+ switch (channelUID.getId()) {
+ case DEVICE_CHANNEL_PAUSE_PLAN_OVERRIDE:
+ strStore.put(DEVICE_CHANNEL_PAUSE_PLAN_OVERRIDE, command.toString());
+ if (OnOffType.ON.equals(command)) {
+ scheduleRenewPlanPause();
+ } else {
+ cancelPlanPauseRenew();
+ }
+ break;
+ case DEVICE_CHANNEL_ACTIVE_WATERING:
+ if (OnOffType.ON.equals(command)) {
+ String volLimit = strStore.get(DEVICE_CHANNEL_OH_VOLUME_LIMIT);
+ if (volLimit == null) {
+ volLimit = DEFAULT_INST_WATERING_VOL_LIMIT;
+ }
+ String durLimit = strStore.get(DEVICE_CHANNEL_OH_DURATION_LIMIT);
+ if (durLimit == null) {
+ durLimit = DEFAULT_INST_WATERING_TIME_LIMIT;
+ }
+ sendRequest(
+ new StartWateringReq(Integer.parseInt(durLimit), Integer.parseInt(volLimit)));
+ } else if (OnOffType.OFF.equals(command)) {
+ sendRequest(new DeviceCmdReq(CMD_IMMEDIATE_WATER_STOP));
+ }
+ case DEVICE_CHANNEL_FALL_STATUS: // 1
+ if (OnOffType.OFF.equals(command)) {
+ sendRequest(new DismissAlertReq(ALERT_DEVICE_FALL));
+ }
+ break;
+ case DEVICE_CHANNEL_SHUTDOWN_FAILURE: // 2
+ if (OnOffType.OFF.equals(command)) {
+ sendRequest(new DismissAlertReq(ALERT_VALVE_SHUTDOWN_FAIL));
+ }
+ break;
+ case DEVICE_CHANNEL_WATER_CUT: // 3
+ if (OnOffType.OFF.equals(command)) {
+ sendRequest(new DismissAlertReq(ALERT_WATER_CUTOFF));
+ }
+ break;
+ case DEVICE_CHANNEL_HIGH_FLOW: // 4
+ if (OnOffType.OFF.equals(command)) {
+ sendRequest(new DismissAlertReq(ALERT_UNEXPECTED_HIGH_FLOW));
+ }
+ break;
+ case DEVICE_CHANNEL_LOW_FLOW: // 5
+ if (OnOffType.OFF.equals(command)) {
+ sendRequest(new DismissAlertReq(ALERT_UNEXPECTED_LOW_FLOW));
+ }
+ break;
+ }
+ }
+ if (!READBACK_DISABLED_CHANNELS.contains(channelUID.getId())) {
+ requestReadbackPoll();
+ }
+ } catch (final InvalidParameterException ipe) {
+ logger.warn("{}",
+ getLocalizedText("warning.parameter-not-accepted", getThing().getLabel(), channelUID.getId()));
+ }
+ });
+ }
+
+ /**
+ * LinkTap communication protocol handlers
+ */
+
+ public void processDeviceCommand(final int commandId, final String frame) {
+ receivedDataPush();
+ logger.debug("{} processing device request with command {}", this.getThing().getLabel(), commandId);
+
+ switch (commandId) {
+ case CMD_UPDATE_WATER_TIMER_STATUS:
+ // Store the latest value in the cache - to prevent unnecessary polls
+ lastPollResultCache.putValue(frame);
+ processCommand3(frame);
+ break;
+ case CMD_NOTIFICATION_WATERING_SKIPPED:
+ case CMD_RAINFALL_DATA:
+ case CMD_DATETIME_SYNC:
+ logger.trace("No implementation for command {} for processing the Device request", commandId);
+ }
+ }
+
+ private void processCommand3(final String request) {
+ // There are three different formats that can arrive in this method:
+ // -> Unsolicited with is a WaterMeterStatus.DeviceStatus payload
+ // -> Solicited with a WaterMeterStatus payload (*)
+ // -> Solicited with a WaterMeterStatus payload within an array
+ // (*) A GSON plugin normalises the non array wrapped version to the array based version
+ // This is handled below before the normalised processing takes place.
+ WaterMeterStatus.DeviceStatus devStatus;
+ {
+ WaterMeterStatus mStatus = GSON.fromJson(request, WaterMeterStatus.class);
+ if (mStatus == null) {
+ return;
+ }
+
+ if (!mStatus.deviceStatuses.isEmpty()) {
+ devStatus = mStatus.deviceStatuses.get(0);
+ } else {
+ devStatus = GSON.fromJson(request, WaterMeterStatus.DeviceStatus.class);
+ }
+ if (devStatus == null) {
+ return;
+ }
+ }
+
+ // Normalized processing below which uses devStatus
+
+ final LinkTapBridgeHandler bridgeHandler = (LinkTapBridgeHandler) getBridgeHandler();
+ String volumeUnit = "L";
+ if (bridgeHandler != null) {
+ String volumeUnitProp = bridgeHandler.getThing().getProperties().get(BRIDGE_PROP_VOL_UNIT);
+ if (volumeUnitProp != null) {
+ volumeUnit = volumeUnitProp;
+ }
+ }
+ String prevPlanId = strStore.get(DEVICE_CHANNEL_WATER_PLAN_ID);
+ if (prevPlanId == null) {
+ prevPlanId = "0";
+ }
+ final String currPlanId = String.valueOf(devStatus.planSerialNo);
+ if (isPlanPauseActive() && !prevPlanId.equals(currPlanId)) {
+ scheduleRenewPlanPause();
+ }
+ updateState(DEVICE_CHANNEL_WATER_PLAN_ID, new StringType(String.valueOf(devStatus.planSerialNo)));
+ strStore.put(DEVICE_CHANNEL_WATER_PLAN_ID, String.valueOf(devStatus.planSerialNo));
+
+ final Integer planModeRaw = devStatus.planMode;
+ if (planModeRaw != null) {
+ updateState(DEVICE_CHANNEL_WATERING_MODE, new StringType(WateringMode.values()[planModeRaw].getDesc()));
+ }
+
+ final Integer childLockRaw = devStatus.childLock;
+ if (childLockRaw != null) {
+ updateState(DEVICE_CHANNEL_CHILD_LOCK, new StringType(ChildLockMode.values()[childLockRaw].getDesc()));
+ }
+
+ updateOnOffValue(DEVICE_CHANNEL_IS_MANUAL_MODE, devStatus.isManualMode);
+ updateOnOffValue(DEVICE_CHANNEL_ACTIVE_WATERING, devStatus.isWatering);
+ updateOnOffValue(DEVICE_CHANNEL_RF_LINKED, devStatus.isRfLinked);
+ updateOnOffValue(DEVICE_CHANNEL_FLM_LINKED, devStatus.isFlmPlugin);
+ updateOnOffValue(DEVICE_CHANNEL_FALL_STATUS, devStatus.isFall);
+ updateOnOffValue(DEVICE_CHANNEL_SHUTDOWN_FAILURE, devStatus.isBroken);
+ updateOnOffValue(DEVICE_CHANNEL_HIGH_FLOW, devStatus.isLeak);
+ updateOnOffValue(DEVICE_CHANNEL_LOW_FLOW, devStatus.isClog);
+ updateOnOffValue(DEVICE_CHANNEL_FINAL_SEGMENT, devStatus.isFinal);
+ updateOnOffValue(DEVICE_CHANNEL_WATER_CUT, devStatus.isCutoff);
+
+ final Integer signal = devStatus.signal;
+ if (signal != null) {
+ updateState(DEVICE_CHANNEL_SIGNAL, new QuantityType<>(signal, Units.PERCENT));
+ }
+
+ final Integer battery = devStatus.battery;
+ if (battery != null) {
+ updateState(DEVICE_CHANNEL_BATTERY, new QuantityType<>(battery, Units.PERCENT));
+ }
+
+ final Integer totalDuration = devStatus.totalDuration;
+ if (totalDuration != null) {
+ updateState(DEVICE_CHANNEL_TOTAL_DURATION, new QuantityType<>(totalDuration, Units.SECOND));
+ }
+
+ final Integer remainDuration = devStatus.remainDuration;
+ if (remainDuration != null) {
+ updateState(DEVICE_CHANNEL_REMAIN_DURATION, new QuantityType<>(remainDuration, Units.SECOND));
+ }
+
+ final Integer failsafeDuration = devStatus.failsafeDuration;
+ if (failsafeDuration != null) {
+ updateState(DEVICE_CHANNEL_FAILSAFE_DURATION, new QuantityType<>(failsafeDuration, Units.SECOND));
+ }
+
+ final Double speed = devStatus.speed;
+ if (speed != null) {
+ updateState(DEVICE_CHANNEL_FLOW_RATE, new QuantityType<>(speed,
+ "L".equals(volumeUnit) ? Units.LITRE_PER_MINUTE : ImperialUnits.GALLON_PER_MINUTE));
+ }
+
+ final Double volume = devStatus.volume;
+ if (volume != null) {
+ updateState(DEVICE_CHANNEL_CURRENT_VOLUME,
+ new QuantityType<>(volume, "L".equals(volumeUnit) ? Units.LITRE : ImperialUnits.GALLON_LIQUID_US));
+ }
+
+ final Double volumeLimit = devStatus.volumeLimit;
+ if (volumeLimit != null) {
+ updateState(DEVICE_CHANNEL_FAILSAFE_VOLUME, new QuantityType<>(volumeLimit,
+ "L".equals(volumeUnit) ? Units.LITRE : ImperialUnits.GALLON_LIQUID_US));
+ }
+ }
+
+ private void updateOnOffValue(final String channelName, final @Nullable Boolean value) {
+ if (value != null) {
+ updateState(channelName, OnOffType.from(value));
+ }
+ }
+
+ @Override
+ public void handleBridgeDataUpdated() {
+ switch (getThing().getStatus()) {
+ case OFFLINE:
+ case UNKNOWN:
+ logger.trace("Handling new bridge data for {}", getThing().getLabel());
+ final LinkTapBridgeHandler bridge = (LinkTapBridgeHandler) getBridgeHandler();
+ if (bridge != null) {
+ if (bridge.getThing().getStatus().equals(ThingStatus.OFFLINE)) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
+ return;
+ }
+ initAfterBridge(bridge);
+ }
+ break;
+ default:
+ logger.trace("Handling new bridge data for {} not required", getThing().getLabel());
+ }
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.internal;
+
+import static org.openhab.binding.linktap.internal.LinkTapBindingConstants.*;
+
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.openhab.binding.linktap.protocol.servers.BindingServlet;
+import org.openhab.binding.linktap.protocol.servers.IHttpClientProvider;
+import org.openhab.core.config.discovery.DiscoveryServiceRegistry;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.i18n.TranslationProvider;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.storage.Storage;
+import org.openhab.core.storage.StorageService;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.binding.BaseThingHandlerFactory;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.http.HttpService;
+
+/**
+ * The {@link LinkTapHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.linktap", service = ThingHandlerFactory.class)
+public class LinkTapHandlerFactory extends BaseThingHandlerFactory implements IHttpClientProvider {
+
+ private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_DEVICE, THING_TYPE_GATEWAY);
+
+ private final StorageService storageService;
+ private final DiscoveryServiceRegistry discSrvReg;
+ private final HttpClientFactory httpClientFactory;
+ private final TranslationProvider translationProvider;
+ private final LocaleProvider localeProvider;
+
+ @Activate
+ public LinkTapHandlerFactory(@Reference HttpService httpService, @Reference StorageService storageService,
+ @Reference DiscoveryServiceRegistry discoveryService, @Reference HttpClientFactory httpClientFactory,
+ @Reference TranslationProvider translationProvider, @Reference LocaleProvider localeProvider) {
+ this.storageService = storageService;
+ this.discSrvReg = discoveryService;
+ this.httpClientFactory = httpClientFactory;
+ BindingServlet.getInstance().setHttpService(httpService);
+ this.translationProvider = translationProvider;
+ this.localeProvider = localeProvider;
+ }
+
+ @Override
+ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+ return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+ }
+
+ @Override
+ protected @Nullable ThingHandler createHandler(Thing thing) {
+ ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+ if (THING_TYPE_DEVICE.equals(thingTypeUID)) {
+ final Storage<String> storage = storageService.getStorage(thing.getUID().toString(),
+ String.class.getClassLoader());
+ return new LinkTapHandler(thing, storage, translationProvider, localeProvider);
+ } else if (THING_TYPE_GATEWAY.equals(thingTypeUID)) {
+ return new LinkTapBridgeHandler((Bridge) thing, this, discSrvReg, translationProvider, localeProvider);
+ }
+
+ return null;
+ }
+
+ @Override
+ public HttpClient getHttpClient() {
+ return httpClientFactory.getCommonHttpClient();
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.internal;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import javax.validation.constraints.NotNull;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link LookupWrapper} is a container providing common functionality for providing
+ * key -> T mappings. The backend store is ConcurrentHashMap.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class LookupWrapper<@Nullable itemT> {
+
+ final Map<@NotNull String, @Nullable itemT> storeLookup = new ConcurrentHashMap<>();
+
+ /**
+ * Register using key the given T instance, and after addition call the specified Runnable
+ *
+ * @param key - The key for the item
+ * @param item - The instance to store a reference to
+ * @param afterAddition - The runnable to run after the addition has been completed
+ * @return - false if another item is already assigned to the key preventing the addition, or true
+ * when added successfully.
+ */
+ public boolean registerItem(final @NotNull String key, final @NotNull itemT item,
+ @NotNull final Runnable afterAddition) {
+ if (storeLookup.containsKey(key)) {
+ final itemT found = storeLookup.get(key);
+ if (found != null && !found.equals(item)) {
+ return false;
+ }
+ }
+ storeLookup.put(key, item);
+ afterAddition.run();
+ return true;
+ }
+
+ /**
+ * Remove the given key and item combination
+ *
+ * @param key - The expected key of the item
+ * @param item - The item referenced by the key
+ * @param whenEmpty - Runnable executed when no more key -> item mappings exist
+ */
+ public void deregisterItem(final @NotNull String key, final @NotNull itemT item,
+ @NotNull final Runnable whenEmpty) {
+ storeLookup.remove(key, item);
+ if (storeLookup.isEmpty()) {
+ whenEmpty.run();
+ }
+ }
+
+ /**
+ * Returns the item associated to the given key
+ *
+ * @param key - the key to find the item for
+ * @return - null if no item is found otherwise the found item
+ */
+ public @Nullable itemT getItem(final @NotNull String key) {
+ return storeLookup.get(key);
+ }
+
+ /**
+ * Clears a entry when only the given key is known
+ *
+ * @param key - the key remove if it exists
+ */
+ public void clearItem(final @NotNull String key) {
+ storeLookup.remove(key);
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.internal;
+
+import static org.openhab.binding.linktap.internal.LinkTapBindingConstants.DEVICE_CHANNEL_OH_DURATION_LIMIT;
+import static org.openhab.binding.linktap.internal.LinkTapBindingConstants.DEVICE_CHANNEL_OH_VOLUME_LIMIT;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.EMPTY_STRING;
+
+import java.time.Duration;
+import java.util.Objects;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import javax.validation.constraints.NotNull;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.linktap.configuration.LinkTapDeviceConfiguration;
+import org.openhab.binding.linktap.protocol.frames.DeviceCmdReq;
+import org.openhab.binding.linktap.protocol.frames.TLGatewayFrame;
+import org.openhab.binding.linktap.protocol.http.DeviceIdException;
+import org.openhab.binding.linktap.protocol.http.InvalidParameterException;
+import org.openhab.core.cache.ExpiringCache;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.i18n.TranslationProvider;
+import org.openhab.core.thing.Bridge;
+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.BridgeHandler;
+import org.openhab.core.types.RefreshType;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.FrameworkUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link PollingDeviceHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public abstract class PollingDeviceHandler extends BaseThingHandler implements IBridgeData {
+
+ protected static final String MARKER_INVALID_DEVICE_KEY = "---INVALID---";
+
+ private final Logger logger = LoggerFactory.getLogger(PollingDeviceHandler.class);
+ private final Object pollLock = new Object();
+ private final Object schedulerLock = new Object();
+ private final Object readBackPollLock = new Object();
+ private final TranslationProvider translationProvider;
+ private final LocaleProvider localeProvider;
+ private final Bundle bundle;
+
+ protected volatile LinkTapDeviceConfiguration config = new LinkTapDeviceConfiguration();
+ private volatile long lastStatusCommandRecvTs = 0L;
+
+ protected String registeredDeviceId = EMPTY_STRING;
+ protected ExpiringCache<String> lastPollResultCache = new ExpiringCache<>(Duration.ofSeconds(5),
+ PollingDeviceHandler::expireCacheContents);
+ private @Nullable ScheduledFuture<?> backgroundGwPollingScheduler;
+ private @Nullable ScheduledFuture<?> readBackPollSf = null;
+
+ protected void requestReadbackPoll() {
+ synchronized (readBackPollLock) {
+ cancelReadbackPoll();
+ scheduler.schedule(() -> {
+ pollForUpdate(true);
+ }, 750, TimeUnit.MILLISECONDS);
+ }
+ }
+
+ protected void cancelReadbackPoll() {
+ synchronized (readBackPollLock) {
+ ScheduledFuture<?> readBackPollSfRef = readBackPollSf;
+ if (readBackPollSfRef != null) {
+ readBackPollSfRef.cancel(false);
+ readBackPollSf = null;
+ }
+ }
+ }
+
+ public PollingDeviceHandler(final Thing thing, TranslationProvider translationProvider,
+ LocaleProvider localeProvider) {
+ super(thing);
+ this.translationProvider = translationProvider;
+ this.localeProvider = localeProvider;
+ this.bundle = FrameworkUtil.getBundle(getClass());
+ }
+
+ public String getLocalizedText(String key, @Nullable Object @Nullable... arguments) {
+ String result = translationProvider.getText(bundle, key, key, localeProvider.getLocale(), arguments);
+ return Objects.nonNull(result) ? result : key;
+ }
+
+ private void startStatusPolling() {
+ synchronized (schedulerLock) {
+ cancelStatusPolling();
+ backgroundGwPollingScheduler = scheduler.scheduleWithFixedDelay(() -> {
+ if (lastStatusCommandRecvTs + 135000 > System.currentTimeMillis()) {
+ return;
+ }
+ pollForUpdate(false);
+ }, 1, 10, TimeUnit.SECONDS);
+ }
+ }
+
+ private void cancelStatusPolling() {
+ synchronized (schedulerLock) {
+ final ScheduledFuture<?> ref = backgroundGwPollingScheduler;
+ if (ref != null) {
+ ref.cancel(true);
+ backgroundGwPollingScheduler = null;
+ }
+ }
+ }
+
+ private static @Nullable String expireCacheContents() {
+ return null;
+ }
+
+ @Override
+ public void initialize() {
+ updateStatus(ThingStatus.UNKNOWN);
+ config = getConfigAs(LinkTapDeviceConfiguration.class);
+ if (!(getBridgeHandler() instanceof LinkTapBridgeHandler bridgeHandler)) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ getLocalizedText("polling-device.error.bridge-unset"));
+ return;
+ } else if (ThingStatus.OFFLINE.equals(bridgeHandler.getThing().getStatus())) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
+ return;
+ }
+
+ scheduler.execute(() -> {
+ if (ThingStatus.ONLINE.equals(bridgeHandler.getThing().getStatus())) {
+ initAfterBridge(bridgeHandler);
+ }
+ });
+ }
+
+ protected void initAfterBridge(final LinkTapBridgeHandler bridge) {
+ String deviceId = getValidatedIdString();
+ if (MARKER_INVALID_DEVICE_KEY.equals(deviceId)) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ getLocalizedText("polling-device.error.device-unknown-in-bridge"));
+ if (!registeredDeviceId.isBlank()) {
+ deregisterDevice();
+ }
+ registeredDeviceId = EMPTY_STRING;
+ return;
+ } else {
+ registeredDeviceId = deviceId;
+ }
+
+ boolean knownToBridge = bridge.getDiscoveredDevices().anyMatch(x -> deviceId.equals(x.deviceId));
+ if (knownToBridge) {
+ updateStatus(ThingStatus.ONLINE);
+ registerDevice();
+ scheduleInitialPoll();
+ scheduler.execute(this::runStartInit);
+ startStatusPolling();
+ } else {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+ getLocalizedText("polling-device.error.unknown-device-id"));
+ }
+ }
+
+ protected abstract void runStartInit();
+
+ protected abstract void registerDevice();
+
+ @Override
+ public void dispose() {
+ cancelInitialPoll(true);
+ deregisterDevice();
+ cancelStatusPolling();
+ }
+
+ protected abstract void deregisterDevice();
+
+ @Nullable
+ BridgeHandler getBridgeHandler() {
+ Bridge bridgeRef = getBridge();
+ if (bridgeRef == null) {
+ return null;
+ } else {
+ return bridgeRef.getHandler();
+ }
+ }
+
+ public String sendRequest(TLGatewayFrame frame) throws InvalidParameterException {
+ if (frame instanceof DeviceCmdReq devCmdReq) {
+ final String deviceAddr = getValidatedIdString();
+ if (deviceAddr.equals(MARKER_INVALID_DEVICE_KEY)) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ getLocalizedText("polling-device.error.unknown-device"));
+ return EMPTY_STRING;
+ }
+ devCmdReq.deviceId = deviceAddr;
+ }
+
+ final Bridge parentBridge = getBridge();
+ if (parentBridge == null) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ getLocalizedText("polling-device.error.bridge-unset"));
+ return EMPTY_STRING;
+ }
+ final LinkTapBridgeHandler parentBridgeHandler = (LinkTapBridgeHandler) parentBridge.getHandler();
+ if (parentBridgeHandler == null) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ getLocalizedText("polling-device.error.bridge-unset"));
+ return EMPTY_STRING;
+ }
+ try {
+ return parentBridgeHandler.sendRequest(frame);
+ } catch (final DeviceIdException die) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, getLocalizedText(die.getI18Key()));
+ }
+ return EMPTY_STRING;
+ }
+
+ @NotNull
+ public String getValidatedIdString() {
+ BridgeHandler bridgeHandler = getBridgeHandler();
+ if (bridgeHandler instanceof LinkTapBridgeHandler vesyncBridgeHandler) {
+ final String devId = config.id;
+
+ // Try to use the device address id directly
+ if (!devId.isEmpty()) {
+ logger.trace("Searching for device address id : {}", devId);
+ @Nullable
+ final LinkTapDeviceMetadata metadata = vesyncBridgeHandler.getDeviceLookup().get(devId);
+
+ if (metadata != null) {
+ return metadata.deviceId;
+ }
+ }
+
+ final String deviceName = config.name;
+
+ // Check if the device name can be matched to a single device
+ if (!deviceName.isEmpty()) {
+ final String[] matchedAddressIds = vesyncBridgeHandler.getDiscoveredDevices()
+ .filter(x -> deviceName.equals(x.deviceName)).map(x -> x.deviceId).toArray(String[]::new);
+
+ for (String val : matchedAddressIds) {
+ logger.trace("Found Address ID match on name with : {}", val);
+ }
+
+ if (matchedAddressIds.length != 1) {
+ return MARKER_INVALID_DEVICE_KEY;
+ }
+
+ return matchedAddressIds[0];
+ }
+ }
+
+ return MARKER_INVALID_DEVICE_KEY;
+ }
+
+ @Override
+ public void channelLinked(ChannelUID channelUID) {
+ super.channelLinked(channelUID);
+
+ if (getThing().getStatusInfo().getStatus() == ThingStatus.ONLINE) {
+ scheduler.execute(() -> {
+ pollForUpdate(false);
+ });
+ }
+ }
+
+ private void scheduleInitialPoll() {
+ cancelInitialPoll(false);
+ initialPollingTask = scheduler.schedule(() -> {
+ // 15 second's is to ensure even slow systems have time to pull the gateway data
+ // ready.
+ sendChannelRefresh(DEVICE_CHANNEL_OH_DURATION_LIMIT);
+ sendChannelRefresh(DEVICE_CHANNEL_OH_VOLUME_LIMIT);
+
+ pollForUpdate(false);
+ }, 15, TimeUnit.SECONDS);
+ }
+
+ private void sendChannelRefresh(final String channelName) {
+ final Channel ch = getThing().getChannel(DEVICE_CHANNEL_OH_VOLUME_LIMIT);
+ if (ch != null) {
+ handleCommand(ch.getUID(), RefreshType.REFRESH);
+ }
+ }
+
+ private void cancelInitialPoll(final boolean interruptAllowed) {
+ final ScheduledFuture<?> pollJob = initialPollingTask;
+ if (pollJob != null && !pollJob.isCancelled()) {
+ pollJob.cancel(interruptAllowed);
+ initialPollingTask = null;
+ }
+ }
+
+ @Nullable
+ // This is used to coalesce poll's for CMD 3 - WATER METER STATUS
+ // otherwise bulk new channel links force many poll's, and an unsolicited update
+ // may recently have already provided the data needed.
+ ScheduledFuture<?> initialPollingTask = null;
+
+ public void pollForUpdate(boolean skipCache) {
+ String response = EMPTY_STRING;
+ synchronized (pollLock) {
+ response = lastPollResultCache.getValue();
+ if (response == null || skipCache) {
+ response = getPollResponseData();
+ lastPollResultCache.putValue(response);
+ }
+ }
+ processPollResponseData(response);
+ }
+
+ protected abstract String getPollResponseData();
+
+ protected abstract void processPollResponseData(final String data);
+
+ protected void receivedDataPush() {
+ lastStatusCommandRecvTs = System.currentTimeMillis();
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.internal;
+
+import static org.openhab.binding.linktap.internal.LinkTapBindingConstants.BRIDGE_PROP_UTC_OFFSET;
+import static org.openhab.binding.linktap.internal.LinkTapBindingConstants.GSON;
+import static org.openhab.binding.linktap.protocol.frames.GatewayDeviceResponse.*;
+import static org.openhab.binding.linktap.protocol.http.TransientCommunicationIssueException.TransientExecptionDefinitions.*;
+
+import java.net.UnknownHostException;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.Objects;
+import java.util.UUID;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.linktap.protocol.frames.DeviceCmdReq;
+import org.openhab.binding.linktap.protocol.frames.GatewayDeviceResponse;
+import org.openhab.binding.linktap.protocol.frames.HandshakeResp;
+import org.openhab.binding.linktap.protocol.frames.TLGatewayFrame;
+import org.openhab.binding.linktap.protocol.frames.WaterMeterStatus;
+import org.openhab.binding.linktap.protocol.http.CommandNotSupportedException;
+import org.openhab.binding.linktap.protocol.http.DeviceIdException;
+import org.openhab.binding.linktap.protocol.http.GatewayIdException;
+import org.openhab.binding.linktap.protocol.http.InvalidParameterException;
+import org.openhab.binding.linktap.protocol.http.NotTapLinkGatewayException;
+import org.openhab.binding.linktap.protocol.http.TransientCommunicationIssueException;
+import org.openhab.binding.linktap.protocol.http.WebServerApi;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.i18n.TranslationProvider;
+import org.openhab.core.thing.ThingStatus;
+import org.osgi.framework.Bundle;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link TransactionProcessor} is a transaction processor, that each Gateway has an instance of.
+ * It is responsible for handling received frames from the Gateway.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public final class TransactionProcessor {
+
+ // The Gateway pushes messages to us, the majority expect a response and are documented as
+ // GW->Broker->App. These are sent via a HTTP request to the WebSerlet listening for the payloads.
+ // Then we can also send data to the Gateway, these all also typically get a response, and are documented as
+ // App->Broker->GW. These are sent via a POST request, to the relevant Gateway.
+ // As the Gateway is an embedded device,
+
+ private static final WebServerApi API = WebServerApi.getInstance();
+ private static final int MAX_COMMAND_RETRIES = 3;
+ private static final TransactionProcessor INSTANCE = new TransactionProcessor();
+
+ private final Logger logger = LoggerFactory.getLogger(TransactionProcessor.class);
+
+ private @Nullable TranslationProvider translationProvider;
+ private @Nullable LocaleProvider localeProvider;
+ private @Nullable Bundle bundle;
+
+ public void setTranslationProviderInfo(TranslationProvider translationProvider, LocaleProvider localeProvider,
+ Bundle bundle) {
+ this.bundle = bundle;
+ this.localeProvider = localeProvider;
+ this.translationProvider = translationProvider;
+ }
+
+ public String getLocalizedText(String key, @Nullable Object @Nullable... arguments) {
+ TranslationProvider translationProv = translationProvider;
+ LocaleProvider localeProv = localeProvider;
+ if (translationProv == null || localeProv == null) {
+ return key;
+ }
+ String result = translationProv.getText(bundle, key, key, localeProv.getLocale(), arguments);
+ return Objects.nonNull(result) ? result : key;
+ }
+
+ private TransactionProcessor() {
+ }
+
+ public static TransactionProcessor getInstance() {
+ return INSTANCE;
+ }
+
+ public String processGwRequest(final String sourceHost, int command, final String payload) {
+ final UUID uid = UUID.randomUUID();
+ logger.debug("{} = GW -> APP Request {} -> Payload {}", uid, sourceHost, payload);
+ String response = "";
+ try {
+ processGw(sourceHost, command, payload);
+ } catch (CommandNotSupportedException cnse) {
+ logger.warn("{}", getLocalizedText("bug-report.gw-unsupported-command", command));
+ }
+ logger.debug("{} = GW -> APP Response {} -> Payload {}", uid, payload, response);
+ return response;
+ }
+
+ public String processGw(final String sourceHost, int command, final String payload)
+ throws CommandNotSupportedException {
+ final GatewayDeviceResponse frame = GSON.fromJson(payload, GatewayDeviceResponse.class);
+ if (frame == null) {
+ return "";
+ }
+
+ final String fromGatewayId = frame.gatewayId;
+ final LinkTapBridgeHandler bridge = LinkTapBridgeHandler.GW_ID_LOOKUP.getItem(fromGatewayId);
+ if (bridge != null) {
+ logger.trace("Found bridge with ID: {} -> {}", fromGatewayId, bridge.getThing().getLabel());
+ } else {
+ logger.trace("Bridge not found with ID: {}", fromGatewayId);
+ }
+
+ // Only the water timer status payload arrives without a command, if there is a command id
+ // then we use the one from the frame instead.
+ command = CMD_UPDATE_WATER_TIMER_STATUS;
+ if (frame.command != DEFAULT_INT) {
+ command = frame.command;
+ }
+
+ final ResultStatus resultStatus = frame.getRes();
+ if (resultStatus == ResultStatus.RET_CMD_NOT_SUPPORTED) {
+ throw new CommandNotSupportedException(resultStatus);
+ }
+
+ String response = "";
+ switch (command) {
+ case CMD_UPDATE_WATER_TIMER_STATUS:
+ WaterMeterStatus meterStatus = GSON.fromJson(payload, WaterMeterStatus.class);
+
+ if (meterStatus != null) {
+ final String devId = meterStatus.deviceStatuses.get(0).deviceId;
+ final LinkTapHandler device = LinkTapBridgeHandler.DEV_ID_LOOKUP.getItem(devId);
+
+ if (device != null) {
+ logger.trace("Found device with ID: {} -> {}", devId, device.getThing().getLabel());
+ device.processDeviceCommand(command, payload);
+ } else {
+ logger.debug("No device with id {} found to process command {}",
+ meterStatus.deviceStatuses.get(0).deviceId, command);
+ }
+ }
+ break;
+ case CMD_HANDSHAKE:
+ case CMD_DATETIME_SYNC:
+ response = generateTimeDateResponse(bridge, frame.gatewayId, command);
+ if (bridge != null) {
+ bridge.processGatewayCommand(CMD_HANDSHAKE, payload);
+ }
+ break;
+ case CMD_NOTIFICATION_WATERING_SKIPPED: {
+ // This does not work - device id is within devStat!
+ final DeviceCmdReq devFrame = GSON.fromJson(payload, DeviceCmdReq.class);
+ if (devFrame != null) {
+ final LinkTapHandler device = LinkTapBridgeHandler.DEV_ID_LOOKUP.getItem(devFrame.deviceId);
+
+ if (device != null) {
+ logger.trace("Found device with ID: {} -> {}", devFrame.deviceId, device.getThing().getLabel());
+ device.processDeviceCommand(command, payload);
+ } else {
+ logger.debug("No device with id {} found to process command {}", devFrame.deviceId, command);
+ }
+ }
+ break;
+ }
+ case CMD_RAINFALL_DATA: {
+ final DeviceCmdReq devFrame = GSON.fromJson(payload, DeviceCmdReq.class);
+ if (devFrame != null) {
+ final LinkTapHandler device = LinkTapBridgeHandler.DEV_ID_LOOKUP.getItem(devFrame.deviceId);
+
+ if (device != null) {
+ logger.trace("Found device with ID: {} -> {}", devFrame.deviceId, device.getThing().getLabel());
+ device.processDeviceCommand(command, payload);
+ } else {
+ logger.trace("No device modelled to process meter status command");
+ }
+ }
+ break;
+ }
+ default:
+ logger.warn("{}", getLocalizedText("warning.unexpected-response-frame", command, payload));
+ }
+ return response;
+ }
+
+ public String sendRequest(final LinkTapBridgeHandler handler, final TLGatewayFrame request)
+ throws GatewayIdException, DeviceIdException, CommandNotSupportedException, InvalidParameterException {
+ if (handler.getThing().getStatus().equals(ThingStatus.OFFLINE)) {
+ logger.trace("Gateway offline");
+ return "";
+ }
+ int triesLeft = MAX_COMMAND_RETRIES;
+ int retry = 0;
+ while (triesLeft > 0) {
+ try {
+ return sendSingleRequest(handler, request);
+ } catch (TransientCommunicationIssueException tcie) {
+ --triesLeft;
+ try {
+ Thread.sleep(1000L * retry);
+ } catch (InterruptedException ie) {
+ return "";
+ }
+ ++retry;
+ }
+ }
+ return "";
+ }
+
+ public String sendSingleRequest(final LinkTapBridgeHandler handler, final TLGatewayFrame request)
+ throws GatewayIdException, DeviceIdException, CommandNotSupportedException, InvalidParameterException,
+ TransientCommunicationIssueException {
+ // We need the hostname from the handler of the bridge
+
+ // Responses can be one of the following types
+ try {
+ UUID uid = UUID.randomUUID();
+ final String targetHost = handler.getHostname();
+ final String payloadJson = GSON.toJson(request);
+ logger.debug("{} = APP -> GW Request {} -> Payload {}", uid, targetHost, payloadJson);
+
+ String response = API.sendRequest(targetHost, GSON.toJson(request));
+ logger.debug("{} = APP -> GW Response {} -> Payload {}", uid, targetHost, response.trim());
+ GatewayDeviceResponse gatewayFrame = GSON.fromJson(response, GatewayDeviceResponse.class);
+
+ if (gatewayFrame == null) {
+ throw new TransientCommunicationIssueException(COMMUNICATIONS_LOST);
+ }
+
+ if (!(request.command == CMD_UPDATE_WATER_TIMER_STATUS && gatewayFrame.command == -1)
+ && request.command != gatewayFrame.command) {
+ logger.warn("{}",
+ getLocalizedText("warning.incorrect-cmd-resp", request.command, gatewayFrame.command));
+ throw new TransientCommunicationIssueException(COMMUNICATIONS_LOST);
+ }
+
+ final ResultStatus rs = gatewayFrame.getRes();
+
+ switch (gatewayFrame.command) {
+ case CMD_ADD_END_DEVICE: // 1
+ case CMD_REMOVE_END_DEVICE: // 2
+ case CMD_UPDATE_WATER_TIMER_STATUS: // 3
+ case CMD_SETUP_WATER_PLAN: // 4
+ case CMD_REMOVE_WATER_PLAN: // 5
+ case CMD_IMMEDIATE_WATER_START: // 6
+ case CMD_IMMEDIATE_WATER_STOP: // 7
+ case CMD_RAINFALL_DATA: // 8
+ case CMD_ALERT_ENABLEMENT: // 10
+ case CMD_ALERT_DISMISS: // 11
+ case CMD_LOCKOUT_STATE: // 12
+ case CMD_DATETIME_READ: // 14
+ case CMD_WIRELESS_CHECK: // 15
+ case CMD_GET_CONFIGURATION: // 16
+ case CMD_SET_CONFIGURATION: // 17
+ case CMD_PAUSE_WATER_PLAN: // 18
+ switch (rs) {
+ case RET_SUCCESS:
+ logger.trace("Request successfully processed");
+ return response;
+ case RET_MESSAGE_FORMAT_ERR:
+ case RET_BAD_PARAMETER:
+ logger.trace("Request issued incorrectly - format or parameter error");
+ throw new InvalidParameterException(rs);
+ case RET_CMD_NOT_SUPPORTED:
+ logger.trace("Command not supported by device");
+ throw new CommandNotSupportedException(rs);
+ case RET_DEVICE_ID_ERROR:
+ case RET_DEVICE_NOT_FOUND:
+ logger.trace("Device configuration error - check DEVICE ID in metadata");
+ throw new DeviceIdException(rs);
+ case RET_GATEWAY_ID_NOT_MATCHED:
+ logger.trace("Gateway configuration error - check GATEWAY ID in metadata");
+ throw new GatewayIdException(rs);
+ case RET_GATEWAY_BUSY:
+ case RET_GW_INTERNAL_ERR:
+ logger.trace("The request can be re-tried");
+ break;
+ case RET_CONFLICT_WATER_PLAN:
+ logger.trace("Gateway rejected command due to water plan conflict");
+ break;
+ case INVALID:
+ default:
+ logger.warn("{}", getLocalizedText("warning.unexpected-cmd-result"));
+ }
+ break;
+ case DEFAULT_INT:
+ if (request.command == CMD_UPDATE_WATER_TIMER_STATUS) {
+ return response;
+ }
+ default:
+ logger.warn("{}", getLocalizedText("warning.unexpected-response-frame", gatewayFrame.command,
+ GSON.toJson(request)));
+ return "";
+ }
+
+ return response;
+ } catch (NotTapLinkGatewayException e) {
+ logger.warn("{}", getLocalizedText("warning.non-gw"));
+ } catch (UnknownHostException e) {
+ throw new TransientCommunicationIssueException(HOST_NOT_RESOLVED);
+ }
+ return "";
+ }
+
+ private String generateTimeDateResponse(final @Nullable LinkTapBridgeHandler bridge, final String gwId,
+ final int commandId) {
+ final LocalDateTime currentTime = LocalDateTime.now();
+ int wday = currentTime.getDayOfWeek().getValue();
+ String dateStr = currentTime.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
+ String timeStr = currentTime.format(DateTimeFormatter.ofPattern("HHmmss"));
+
+ // Presume we are running in local time, unless we have the bridge modelled with the relevant UTC data
+ if (bridge != null) {
+ final String utcOffset = bridge.getThing().getProperties().get(BRIDGE_PROP_UTC_OFFSET);
+ if (utcOffset != null && !utcOffset.isEmpty()) {
+ OffsetDateTime odt = currentTime.atOffset(ZoneOffset.UTC);
+ odt = odt.plusSeconds(Long.parseLong(utcOffset));
+ wday = odt.getDayOfWeek().getValue();
+ dateStr = odt.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
+ timeStr = odt.format(DateTimeFormatter.ofPattern("HHmmss"));
+ }
+ }
+
+ final HandshakeResp respPayload = new HandshakeResp();
+ respPayload.command = commandId;
+ respPayload.gatewayId = gwId;
+ respPayload.wday = wday;
+ respPayload.date = dateStr;
+ respPayload.time = timeStr;
+ return GSON.toJson(respPayload);
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link Utils} contains static function's for useful functionality.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public final class Utils {
+
+ private Utils() {
+ }
+
+ /**
+ * This cleans a string down to characters relevant to a mdns reply
+ * '()*+,-./0-9:;<=>?@[\]^_`a-z{|}~ ensuring characters not included
+ * in this range are removed.
+ *
+ * @param chars - The string to be cleansed
+ * @return The string with only the relevant characters included
+ */
+ public static String cleanPrintableChars(final String chars) {
+ final StringBuilder stBldr = new StringBuilder(chars.length());
+ for (char ch : chars.toCharArray()) {
+ final byte chBy = (byte) ch;
+ if (chBy >= 32 && chBy <= 126) {
+ stBldr.append(ch);
+ }
+ }
+ return stBldr.toString();
+ }
+
+ /**
+ * Return the localized error message if available otherwise the
+ * message. Should they both (either be null or empty) then return the
+ * class name of the throwable.
+ *
+ * @param t - The throwable to extract the data from
+ * @return The most representative text for the message
+ */
+ public static String getMessage(final @Nullable Throwable t) {
+ if (t == null) {
+ return "?";
+ }
+
+ final String localizedMsg = t.getLocalizedMessage();
+ if (localizedMsg != null) {
+ if (!localizedMsg.isBlank()) {
+ return localizedMsg;
+ }
+ }
+ final String msg = t.getMessage();
+ if (msg != null) {
+ return msg;
+ }
+ return t.getClass().getName();
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link AlertStateReq} defines the request to enable or disable alerts from a given device.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class AlertStateReq extends DismissAlertReq {
+
+ public AlertStateReq() {
+ }
+
+ public AlertStateReq(final int alert, final boolean enable) {
+ this.command = CMD_ALERT_ENABLEMENT;
+ this.alert = alert;
+ this.enable = enable;
+ }
+
+ /**
+ * Defines the alert type to be enabled or disabled
+ */
+ @SerializedName("enable")
+ @Expose
+ public boolean enable;
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import java.util.Collection;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link DeviceCmdReq} is a request targetted to a device.
+ *
+ * @provides App: Device ID
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class DeviceCmdReq extends TLGatewayFrame {
+
+ public DeviceCmdReq() {
+ }
+
+ public DeviceCmdReq(final int command) {
+ this.command = command;
+ }
+
+ /**
+ * Defines the targetted device ID
+ */
+ @SerializedName("dev_id")
+ @Expose
+ public String deviceId = EMPTY_STRING;
+
+ public Collection<ValidationError> getValidationErrors() {
+ Collection<ValidationError> errors = super.getValidationErrors();
+
+ if (!DEVICE_ID_PATTERN.matcher(deviceId).matches() && !SUB_DEVICE_ID_PATTERN.matcher(deviceId).matches()) {
+ errors.add(new ValidationError("dev_id", "is invalid"));
+ }
+
+ return errors;
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import java.util.Collection;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link DismissAlertReq} defines the request to dismiss alerts from a given device.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class DismissAlertReq extends DeviceCmdReq {
+
+ public DismissAlertReq() {
+ }
+
+ public DismissAlertReq(final int alert) {
+ this.command = CMD_ALERT_DISMISS;
+ this.alert = alert;
+ }
+
+ /**
+ * Defines the alert type the dismiss is for.
+ */
+ @SerializedName("alert")
+ @Expose
+ public int alert = DEFAULT_INT;
+
+ @Override
+ public Collection<ValidationError> getValidationErrors() {
+ Collection<ValidationError> errors = super.getValidationErrors();
+
+ if (alert < ALERT_TYPES_ALL || alert > ALERT_UNEXPECTED_LOW_FLOW) {
+ errors.add(new ValidationError("alert",
+ "not in range " + ALERT_TYPES_ALL + " -> " + ALERT_UNEXPECTED_LOW_FLOW));
+ }
+
+ return errors;
+ }
+
+ /**
+ * Alert - 0. All types of alert
+ */
+ public static final int ALERT_TYPES_ALL = 0;
+
+ /**
+ * Alert - 1. Device fall alert
+ */
+ public static final int ALERT_DEVICE_FALL = 1;
+
+ /**
+ * Alert - 2. Valve shutdown failure alert
+ */
+ public static final int ALERT_VALVE_SHUTDOWN_FAIL = 2;
+
+ /**
+ * Alert - 3. Water cut-off alert
+ */
+ public static final int ALERT_WATER_CUTOFF = 3;
+
+ /**
+ * Alert - 4. Unusually high flow alert
+ */
+ public static final int ALERT_UNEXPECTED_HIGH_FLOW = 4;
+
+ /**
+ * Alert - 5. Unusually low flow alert
+ */
+ public static final int ALERT_UNEXPECTED_LOW_FLOW = 5;
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import java.util.Collection;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link EndpointDeviceResponse} defines the response from the Gateway which includes
+ * the targetted endpoint device ID.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class EndpointDeviceResponse extends GatewayDeviceResponse {
+
+ public EndpointDeviceResponse() {
+ }
+
+ /**
+ * Defines the Endpoint Device ID the return value is for
+ */
+ @SerializedName("dev_id")
+ @Expose
+ public String deviceId = EMPTY_STRING;
+
+ public Collection<ValidationError> getValidationErrors() {
+ final Collection<ValidationError> errors = super.getValidationErrors();
+
+ if (!DEVICE_ID_PATTERN.matcher(deviceId).matches()) {
+ errors.add(new ValidationError("dev_id", "is invalid"));
+ }
+
+ return errors;
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import java.util.Collection;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link HandshakeResp} informs the Gateway of the current date, time and weekday in response to
+ * a HandshakeReq Frame.
+ *
+ * @provides Gw: Expects response of HandshakeResp, to inform the Gateway of the current local Date and Time
+ * @replyTo HandshakeReq
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class GatewayConfigResp extends HandshakeReq {
+
+ public GatewayConfigResp() {
+ }
+
+ /**
+ * Defines the units of measurement for volume
+ * L = Litres
+ * gal = Gallon
+ */
+ @SerializedName("vol_unit")
+ @Expose
+ public String volumeUnit = EMPTY_STRING;
+
+ /**
+ * Defines the UTC offset the gateway TZ is located in (seconds offset)
+ */
+ @SerializedName("utc_ofs")
+ @Expose
+ public Integer utfOfs = DEFAULT_INT;
+
+ /**
+ * Defines the names assigned to each Endpoint device.
+ */
+ @SerializedName("dev_name")
+ @Expose
+ public String[] deviceNames = EMPTY_STRING_ARRAY;
+
+ public Collection<ValidationError> getValidationErrors() {
+ final Collection<ValidationError> errors = super.getValidationErrors();
+
+ if (deviceNames.length != endDevices.length) {
+ errors.add(new ValidationError("dev_name,end_dev", "DeviceNames != EndDevices length"));
+ }
+
+ return errors;
+ }
+
+ /**
+ * Unit Volume - Unit tag for gallons
+ */
+ public static final String UNIT_VOL_GALLON = "gal";
+
+ /**
+ * Unit Volume - Unit tag for litres
+ */
+ public static final String UNIT_VOL_LITRES = "L";
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import java.util.Collection;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link GatewayDeviceResponse} defines the response from the Gateway when a status
+ * is given about the state of the requested command.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class GatewayDeviceResponse extends TLGatewayFrame {
+
+ public GatewayDeviceResponse() {
+ }
+
+ /**
+ * Defines the processing result from the gateway
+ */
+ @SerializedName("ret")
+ @Expose(serialize = false, deserialize = true)
+ private @Nullable Integer returnValue = null;
+ @Expose(serialize = false, deserialize = false)
+ private ResultStatus cachedResEnum = ResultStatus.INVALID;
+
+ public ResultStatus getRes() {
+ if (cachedResEnum == ResultStatus.INVALID) {
+ final Integer retValClone = returnValue;
+ if (retValClone != null) {
+ cachedResEnum = ResultStatus.values()[retValClone.intValue()];
+ }
+ }
+ return cachedResEnum;
+ }
+
+ public boolean isSuccess() {
+ return ResultStatus.RET_SUCCESS == getRes();
+ }
+
+ public boolean isRetryableError() {
+ switch (getRes()) {
+ case RET_CONFLICT_WATER_PLAN: // Conflict with watering plan
+ case RET_GW_INTERNAL_ERR: // Gateway internal error
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ public Collection<ValidationError> getValidationErrors() {
+ final Collection<ValidationError> errors = super.getValidationErrors();
+
+ if (ResultStatus.INVALID == getRes()) {
+ errors.add(new ValidationError("res", "is invalid"));
+ }
+
+ return errors;
+ }
+
+ public enum ResultStatus {
+
+ /**
+ * RET_SUCCESS (Ordinal 0).
+ */
+ RET_SUCCESS(0, "Success", false, "protocol.ret.success"),
+
+ /**
+ * RET_MESSAGE_FORMAT_ERR (Ordinal 1).
+ */
+ RET_MESSAGE_FORMAT_ERR(1, "Message format error", false, "protocol.ret.format-error"),
+
+ /**
+ * RET_CMD_NOT_SUPPORTED (Ordinal 2).
+ */
+ RET_CMD_NOT_SUPPORTED(2, "CMD message not supported", false, "protocol.ret.cmd-unsupported"),
+
+ /**
+ * RET_GATEWAY_ID_NOT_MATCHED (Ordinal 3).
+ */
+ RET_GATEWAY_ID_NOT_MATCHED(3, "Gateway ID not matched", false, "protocol.ret.gw-id-unmatched"),
+
+ /**
+ * RET_DEVICE_ID_ERROR (Ordinal 4).
+ */
+ RET_DEVICE_ID_ERROR(4, "End device ID error", false, "protocol.ret.end-device-id-error"),
+
+ /**
+ * RET_DEVICE_NOT_FOUND (Ordinal 5).
+ */
+ RET_DEVICE_NOT_FOUND(5, "End device ID not found", false, "protocol.ret.end-device-id-not-found"),
+
+ /**
+ * RET_GW_INTERNAL_ERR (Ordinal 6).
+ */
+ RET_GW_INTERNAL_ERR(6, "Gateway internal error", true, "protocol.ret.gw-internal-error"),
+
+ /**
+ * RET_CONFLICT_WATER_PLAN (Ordinal 7).
+ */
+ RET_CONFLICT_WATER_PLAN(7, "Conflict with watering plan", false, "protocol.ret.conflict-watering-plan"),
+
+ /**
+ * RET_GATEWAY_BUSY (Ordinal 8).
+ */
+ RET_GATEWAY_BUSY(8, "Gateway busy", true, "protocol.ret.gw-busy"),
+
+ /**
+ * RET_BAD_PARAMETER (Ordinal 9).
+ */
+ RET_BAD_PARAMETER(9, "Bad parameter in message", false, "protocol.ret.bad-parameter-in-msg"),
+
+ /**
+ * INVALID (Ordinal -1).
+ */
+ INVALID(-1, "Not Provided", false, "protocol.ret.invalid");
+
+ private final int value;
+ private final String description;
+ private final boolean retry;
+ private final String i18Key;
+
+ private ResultStatus(final int value, final String description, final boolean retry, final String i18Key) {
+ this.value = value;
+ this.description = description;
+ this.retry = retry;
+ this.i18Key = i18Key;
+ }
+
+ public int getValue() {
+ return value;
+ }
+
+ public String getDesc() {
+ return description;
+ }
+
+ public boolean getCanRetry() {
+ return retry;
+ }
+
+ public String getI18Key() {
+ return i18Key;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("%d - %s", value, description);
+ }
+ }
+
+ /*
+ * public static final int RET_SUCCESS = 0;
+ * public static final int RET_MESSAGE_FORMAT_ERR = 1;
+ * public static final int RET_CMD_NOT_SUPPORTED = 2;
+ * public static final int RET_GATEWAY_ID_NOT_MATCHED = 3;
+ * public static final int RET_DEVICE_ID_ERROR = 4;
+ * public static final int RET_DEVICE_NOT_FOUND = 5;
+ * public static final int RET_GW_INTERNAL_ERR = 6;
+ * public static final int RET_CONFLICT_WATER_PLAN = 7;
+ * public static final int RET_GATEWAY_BUSY = 8;
+ * public static final int RET_BAD_PARAMETER = 9;
+ */
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import java.util.Collection;
+import java.util.regex.Pattern;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link GatewayEndDevListReq} is a reusable frame used for multiple commands where a device endpoint list is
+ * included.
+ *
+ * @provides: Endpoint Device ID List
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class GatewayEndDevListReq extends TLGatewayFrame {
+
+ protected static final Pattern FULL_DEVICE_ID_PATTERN = Pattern.compile("[a-zA-Z0-9]{20}");
+
+ public GatewayEndDevListReq() {
+ }
+
+ /**
+ * Defines the endpoint devices added / registered to the Gateway.
+ * Limited to the first 16 digits and letters of the Device ID
+ */
+ @SerializedName("end_dev")
+ @Expose
+ public String[] endDevices = EMPTY_STRING_ARRAY;
+
+ public Collection<ValidationError> getValidationErrors() {
+ final Collection<ValidationError> errors = super.getValidationErrors();
+
+ for (String ed : endDevices) {
+ if (command == CMD_ADD_END_DEVICE) {
+ if (!FULL_DEVICE_ID_PATTERN.matcher(ed).matches()) {
+ errors.add(new ValidationError("end_dev", "endDevice " + ed + " invalid"));
+ }
+ } else {
+ if (!DEVICE_ID_PATTERN.matcher(ed).matches()) {
+ errors.add(new ValidationError("end_dev", "endDevice " + ed + " invalid"));
+ }
+ }
+ }
+ return errors;
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import java.util.Collection;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link HandshakeReq} is a handshake from the Gateway.
+ *
+ * @provides App: Gateway ID, Firmware revision, Registered / Addded Endpoint Device ID List
+ * @response Gw: Expects response of HandshakeResp, to inform the Gateway of the current local Date and Time
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class HandshakeReq extends GatewayEndDevListReq {
+
+ public HandshakeReq() {
+ }
+
+ /**
+ * Defines the firmware version identifier.
+ */
+ @SerializedName("ver")
+ @Expose
+ public String version = EMPTY_STRING;
+
+ public Collection<ValidationError> getValidationErrors() {
+ final Collection<ValidationError> errors = super.getValidationErrors();
+
+ if (version.isEmpty()) {
+ errors.add(new ValidationError("ver", "nis empty"));
+ }
+ return errors;
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.Collection;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link HandshakeResp} informs the Gateway of the current date, time and weekday in response to
+ * a HandshakeReq Frame.
+ *
+ * @provides Gw: Expects response of HandshakeResp, to inform the Gateway of the current local Date and Time
+ * @replyTo HandshakeReq
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class HandshakeResp extends GatewayDeviceResponse {
+
+ public HandshakeResp() {
+ }
+
+ /**
+ * Defines the date in the format YYYYMMDD
+ */
+ @SerializedName("date")
+ @Expose
+ public String date = EMPTY_STRING;
+
+ /**
+ * Defines the time for the GW in the format HHMMSS
+ */
+ @SerializedName("time")
+ @Expose
+ public String time = EMPTY_STRING;
+
+ /**
+ * Defines the weekday for the GW
+ * 1 represents Monday.... 7 represents Sunday
+ */
+ @SerializedName("wday")
+ @Expose
+ public int wday = DEFAULT_INT;
+
+ static final String DATE_PATTERN = "yyyyMMdd";
+ static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern(DATE_PATTERN);
+
+ static final String TIME_PATTERN = "HHmmss";
+ static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern(TIME_PATTERN);
+
+ public Collection<ValidationError> getValidationErrors() {
+ final Collection<ValidationError> errors = super.getValidationErrors();
+
+ if (wday < 1 || wday > 7) {
+ errors.add(new ValidationError("wday", "not in range 1 -> 7"));
+ }
+ try {
+ LocalDate.parse(date, DATE_FORMATTER);
+ } catch (DateTimeParseException e) {
+ errors.add(new ValidationError("date", "is invalid"));
+ }
+
+ try {
+ LocalTime.parse(time, TIME_FORMATTER);
+ } catch (DateTimeParseException e) {
+ errors.add(new ValidationError("time", "is invalid"));
+ }
+
+ return errors;
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link IPayloadValidator} when implemented for frame definitions, allows the payload's to
+ * be validated as accurate data.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public interface IPayloadValidator {
+
+ /**
+ * This will return any validation errors with the payload, or otherwise
+ * a empty Collection.
+ *
+ * @return Collection of ValidationError instances highlighting payload issues
+ */
+ Collection<ValidationError> getValidationErrors();
+
+ Collection<ValidationError> EMPTY_COLLECTION = Collections
+ .unmodifiableCollection(new ArrayList<ValidationError>(0));
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import java.util.Collection;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link LockReq} defines the request to dismiss alerts from a given device.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class LockReq extends DeviceCmdReq {
+
+ public LockReq() {
+ }
+
+ public LockReq(final int lock) {
+ this.command = CMD_LOCKOUT_STATE;
+ this.lock = lock;
+ }
+
+ /**
+ * Defines the lock type to reqest
+ */
+ @SerializedName("lock")
+ @Expose
+ public int lock = DEFAULT_INT;
+
+ public Collection<ValidationError> getValidationErrors() {
+ final Collection<ValidationError> errors = super.getValidationErrors();
+
+ if (lock < LOCK_UNLOCKED || lock > LOCK_FULL) {
+ errors.add(new ValidationError("lock", "not in range " + LOCK_UNLOCKED + " -> " + LOCK_FULL));
+ }
+ return errors;
+ }
+
+ /**
+ * Lock - 0. Device is unlocked
+ */
+ public static final int LOCK_UNLOCKED = 0;
+
+ /**
+ * Lock - 1. Partially locked
+ */
+ public static final int LOCK_PARTIALLY = 1;
+
+ /**
+ * Lock - 2. Completely locked
+ */
+ public static final int LOCK_FULL = 2;
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import java.util.Collection;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link PauseWateringPlanReq} requests the watering plan is disabled for a duration of hours.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class PauseWateringPlanReq extends DeviceCmdReq {
+
+ public PauseWateringPlanReq() {
+ }
+
+ public PauseWateringPlanReq(final double duration) {
+ this.command = CMD_PAUSE_WATER_PLAN;
+ this.duration = duration;
+ }
+
+ /**
+ * Defines the duration the watering plan is to be paused for.
+ * Acceptable range is between 0.1 to 240
+ * Units is hours
+ */
+ @SerializedName("duration")
+ @Expose
+ public Double duration = 0.0;
+
+ public Collection<ValidationError> getValidationErrors() {
+ final Collection<ValidationError> errors = super.getValidationErrors();
+
+ if (duration < 0.1 || duration > 240) {
+ errors.add(new ValidationError("rain", "not in range 0.1 -> 240"));
+ }
+ return errors;
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import java.util.Collection;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link DismissAlertReq} defines the payload to represent rainfall data.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class RainData extends TLGatewayFrame {
+
+ public RainData() {
+ }
+
+ /**
+ * Defines the past rainfall [0] and future rainfull [1] measurements in mm
+ */
+ @SerializedName("rain")
+ @Expose
+ public double[] rainfallData = new double[] { 0.0, 0.0 };
+
+ public void setPastRainfall(final double pastRainMM) {
+ rainfallData[0] = pastRainMM;
+ }
+
+ public double getPastRainfall() {
+ return rainfallData[0];
+ }
+
+ public void setFutureRainfall(final double futureRainMM) {
+ rainfallData[1] = futureRainMM;
+ }
+
+ public double getFutureRainfall() {
+ return rainfallData[1];
+ }
+
+ public Collection<ValidationError> getValidationErrors() {
+ final Collection<ValidationError> errors = super.getValidationErrors();
+
+ if (rainfallData.length != 2) {
+ errors.add(new ValidationError("rain", "has a invalid number of parameters"));
+ }
+
+ return errors;
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import java.util.Collection;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link DismissAlertReq} defines the payload to represent rainfall forecast data.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class RainDataForecast extends RainData {
+
+ public RainDataForecast() {
+ }
+
+ /**
+ * Defines the effective time relevant for the rainfall data returned.
+ * Minimum value is 1
+ */
+ @SerializedName("valid_duration")
+ @Expose
+ public int validDuration = DEFAULT_INT;
+
+ public Collection<ValidationError> getValidationErrors() {
+ final Collection<ValidationError> errors = super.getValidationErrors();
+
+ if (validDuration < 1) {
+ errors.add(new ValidationError("valid_duration", "is less than 1"));
+ }
+
+ return errors;
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import java.util.Collection;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link SetDeviceConfigReq} sets the configuration parameter specified for a particular device.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class SetDeviceConfigReq extends DeviceCmdReq {
+
+ public SetDeviceConfigReq() {
+ }
+
+ public SetDeviceConfigReq(final String tag, final int value) {
+ this.command = CMD_SET_CONFIGURATION;
+ this.tag = tag;
+ this.value = value;
+ }
+
+ /**
+ * The value to send for the given tag
+ */
+ @SerializedName("value")
+ @Expose
+ public int value = 0;
+
+ /**
+ * The tag that the value suppied is to be used for
+ */
+ @SerializedName("tag")
+ @Expose
+ public String tag = EMPTY_STRING;
+
+ public Collection<ValidationError> getValidationErrors() {
+ final Collection<ValidationError> errors = super.getValidationErrors();
+ switch (tag) {
+ case CONFIG_VOLUME_LIMIT:
+ case CONFIG_DURATION_LIMIT:
+ break;
+ default:
+ errors.add(new ValidationError("tag", "invalid tag \"" + tag + "\""));
+ }
+
+ return errors;
+ }
+
+ /**
+ * Config - Water Volume Limit
+ */
+ public static final String CONFIG_VOLUME_LIMIT = "volume_limit";
+
+ /**
+ * Config - Time Duration Limit
+ */
+ public static final String CONFIG_DURATION_LIMIT = "total_duration";
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import static org.openhab.binding.linktap.protocol.frames.HandshakeResp.DATE_FORMATTER;
+import static org.openhab.binding.linktap.protocol.frames.ValidationError.Cause.BUG;
+import static org.openhab.binding.linktap.protocol.frames.ValidationError.Cause.USER;
+import static org.openhab.binding.linktap.protocol.frames.WaterMeterStatus.OP_MODE_INSTANT;
+import static org.openhab.binding.linktap.protocol.frames.WaterMeterStatus.OP_MODE_MONTH;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collection;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link SetupWaterPlan} defines the request to dismiss alerts from a given device.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public abstract class SetupWaterPlan extends DeviceCmdReq {
+
+ public SetupWaterPlan() {
+ }
+
+ /**
+ * Defines the unique identifier for the instance of the watering plan
+ * that we are sending.
+ */
+ @SerializedName("plan_sn")
+ @Expose
+ public int planSerialNo = DEFAULT_INT;
+
+ /**
+ * Defines the watering mode which can be:
+ * watering mode (1 - Instant Mode, 2 - Calendar mode, 3 - 7 day mode, 4 - Odd-even mode, 5
+ * - Interval mode, 6 - Month mode).
+ * See OP_MODE_INSTANT....
+ */
+ @SerializedName("mode")
+ @Expose
+ public int mode = DEFAULT_INT;
+
+ /**
+ * Defines the eco mode options... only used by Instant mode but in all payloads
+ * eco: the ECO mode works in a way that the valve opens for X seconds then closes
+ * for Y seconds “X, Y, X, Y, …“. in [X, Y], X denotes the Valve ON duration,
+ * Y denotes Valve OFF duration. The ECO mode will not be applied if either X or Y is zero.
+ */
+ protected int[] eco = new int[] { 0, 0 };
+
+ /**
+ * Defines the watering plan information for the mode specified
+ */
+ @SerializedName("sch")
+ @Expose
+ public WaterSchedule schedule = new WaterSchedule();
+
+ protected class WaterSchedule implements IPayloadValidator {
+ @Override
+ public Collection<ValidationError> getValidationErrors() {
+ return EMPTY_COLLECTION;
+ }
+ }
+
+ public Collection<ValidationError> getValidationErrors() {
+ final Collection<ValidationError> errors = super.getValidationErrors();
+
+ if (planSerialNo == DEFAULT_INT) {
+ errors.add(new ValidationError("plan_sn", "is invalid"));
+ }
+ if (mode < OP_MODE_INSTANT || mode > OP_MODE_MONTH) {
+ errors.add(new ValidationError("mode", "mode not in range " + OP_MODE_INSTANT + " -> " + OP_MODE_MONTH));
+ }
+ if (eco.length != 2) {
+ errors.add(new ValidationError("eco", "number of parameters is incorrect"));
+ }
+ return errors;
+ }
+
+ public class WaterPlanInstant extends WaterSchedule {
+
+ /**
+ * Defines the timestamp (YYYYMMDDHHMMSS) that the Instant Mode will take effect
+ */
+ @SerializedName("timestamp")
+ @Expose
+ public String timestamp = EMPTY_STRING;
+
+ /**
+ * Defines the target capacity for a watering session, measured in liters or gallons
+ * (according to the Volume unit configuration in the gateway management page).
+ * The minimum value is 1. When the water timer has a flow meter connected, if its
+ * value is greater than 0, the watering process is controlled by both "volume" and "duration."
+ * The watering stops when either of these conditions is met.
+ * (Note: G1S does not support "watering by volume". D1, which includes two integrated flow meters,
+ * supports "watering by volume")
+ */
+ @SerializedName("volume")
+ @Expose
+ public int volume = DEFAULT_INT;
+
+ /**
+ * This defines the watering duration in seconds. (Please note: for G1 & G2 models which support "watering
+ * by minute" only, the duration value here needs to be an integral multiple of 60 seconds. For the G1S & G2S
+ * and future models, the duration value can be any integer between 3 and 86399.)
+ */
+ @SerializedName("duration")
+ @Expose
+ public int duration = DEFAULT_INT;
+
+ @Override
+ public Collection<ValidationError> getValidationErrors() {
+ final Collection<ValidationError> errors = new ArrayList<>(0);
+ if (duration < 3 || duration > 86399) {
+ errors.add(new ValidationError("duration", "not in range 3 -> 86340", USER));
+ }
+ if (volume < 1) {
+ errors.add(new ValidationError("volume", "is less than 1", USER));
+ }
+ if (timestamp.length() != 14) {
+ errors.add(new ValidationError("timestamp", "is not 14 characters long", BUG));
+ }
+ try {
+ LocalDate.parse(timestamp.substring(0, 8), DATE_FORMATTER);
+ LocalTime.parse(timestamp.substring(9), DATE_FORMATTER);
+ } catch (DateTimeParseException e) {
+ errors.add(new ValidationError("timestamp", "is invalid", BUG));
+ }
+
+ return errors;
+ }
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import static org.openhab.binding.linktap.protocol.frames.ValidationError.Cause.USER;
+
+import java.util.Collection;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link DismissAlertReq} defines the request to start watering immediately.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class StartWateringReq extends DeviceCmdReq {
+
+ public StartWateringReq() {
+ }
+
+ public StartWateringReq(final int durationSecs, final int volume) {
+ this.command = CMD_IMMEDIATE_WATER_START;
+ this.duration = durationSecs;
+ this.volume = volume;
+ }
+
+ /**
+ * Defines the time duration in seconds to water for.
+ * Minimum value 3
+ * Maximum value 86340
+ */
+ @SerializedName("duration")
+ @Expose
+ public int duration = DEFAULT_INT;
+
+ /**
+ * Defines the volume of water to use
+ * Units may be L or gal, depending on the units the device
+ * is operating in.
+ */
+ @SerializedName("volume")
+ @Expose
+ public int volume = DEFAULT_INT;
+
+ public Collection<ValidationError> getValidationErrors() {
+ final Collection<ValidationError> errors = super.getValidationErrors();
+
+ if (duration < 3 || duration > 86340) {
+ errors.add(new ValidationError("duration", "not in range 3 -> 86340", USER));
+ }
+
+ return errors;
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.regex.Pattern;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link TLGatewayFrame} defines the common framing data, for requests and responses
+ * from a Gateway device.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class TLGatewayFrame implements IPayloadValidator {
+
+ public static final int DEFAULT_INT = -1;
+ public static final String EMPTY_STRING = "";
+ public static final String[] EMPTY_STRING_ARRAY = new String[0];
+
+ protected static final Pattern DEVICE_ID_PATTERN = Pattern.compile("[a-zA-Z0-9]{0,16}");
+ protected static final Pattern SUB_DEVICE_ID_PATTERN = Pattern.compile("[a-zA-Z0-9]{0,16}_[0-9]");
+
+ public TLGatewayFrame() {
+ }
+
+ public TLGatewayFrame(final int command) {
+ this.command = command;
+ }
+
+ public @Nullable Class<TLGatewayFrame> getResponseFrame() {
+ return TLGatewayFrame.class;
+ }
+
+ /**
+ * Defines the CMD identifier, that defines the payload type.
+ * Possible values in constants CMD_*
+ */
+ @SerializedName("cmd")
+ @Expose
+ public int command = DEFAULT_INT;
+
+ /**
+ * Defines the gateway identifier.
+ * Limited to the first 16 digits and letters of the Gateway ID
+ */
+ @SerializedName("gw_id")
+ @Expose
+ public String gatewayId = EMPTY_STRING;
+
+ public Collection<ValidationError> getValidationErrors() {
+ final ArrayList<ValidationError> errors = new ArrayList<>(0);
+ if (command < CMD_HANDSHAKE || command > CMD_PAUSE_WATER_PLAN) {
+ errors.add(new ValidationError("cmd", "not in range " + CMD_HANDSHAKE + " -> " + CMD_PAUSE_WATER_PLAN));
+ }
+ if (!DEVICE_ID_PATTERN.matcher(gatewayId).matches()) {
+ errors.add(new ValidationError("gw_id", "not in range " + CMD_HANDSHAKE + " -> " + CMD_PAUSE_WATER_PLAN));
+ }
+ return errors;
+ }
+
+ // COMMAND Values
+
+ /**
+ * Command - 0. Handshake Message
+ *
+ * @direction GW->Broker->App
+ * @description Handshake message. It is the first message after the Gateway connects to the MQTT Broker.
+ * </p>
+ * Gateway acquires its local time base from third-party application, and sends its
+ * end devices ID list to third-party application, through this message.
+ */
+ public static final int CMD_HANDSHAKE = 0;
+
+ /**
+ * Command - 1. Add / Register End Device
+ *
+ * @direction App->Broker->GW
+ * @description Add's / Registers a new device to the Gateway
+ * (e.g., water timer) to the Gateway.
+ */
+ public static final int CMD_ADD_END_DEVICE = 1;
+
+ /**
+ * Command - 2. Delete End Device
+ *
+ * @direction App->Broker->GW
+ * @description Removes / De-registers a device (e.g., water timer) from the Gateway.
+ */
+ public static final int CMD_REMOVE_END_DEVICE = 2;
+
+ /**
+ * Command - 3. Update Water Timer Status
+ *
+ * @direction App->Broker->GW
+ * @description Update water timer’s status
+ */
+ public static final int CMD_UPDATE_WATER_TIMER_STATUS = 3;
+
+ /**
+ * Command - 103. Update Water Timer Status Unsolicited
+ *
+ * @direction GW->Broker->App
+ * @description Update water timer’s status
+ */
+ public static final int CMD_UPDATE_WATER_TIMER_STATUS_UNSOLICITED = 103;
+
+ /**
+ * Command - 4. Send / Setup Water Plan
+ *
+ * @direction App->Broker->GW
+ * @description Send / set up watering plan
+ * (The prerequisite for the correct execution of the watering plan is that
+ * the Gateway’s local time base has been properly set through
+ * CMD:0 (CMD_HANDSHAKE) or
+ * CMD:13)
+ */
+ public static final int CMD_SETUP_WATER_PLAN = 4;
+
+ /**
+ * Command - 5. Delete Water Plan
+ *
+ * @direction App->Broker->GW
+ * @description Deletes the existing water plan
+ */
+ public static final int CMD_REMOVE_WATER_PLAN = 5;
+
+ /**
+ * Command - 6. Start Watering Immediately
+ *
+ * @direction App->Broker->GW
+ * @description Start watering for the immediate duration irrelevant
+ * of water plan. (Gateway local time base is not required
+ * for the operation of this mode).
+ */
+ public static final int CMD_IMMEDIATE_WATER_START = 6;
+
+ /**
+ * Command - 7. Stop Watering Immediately
+ *
+ * @direction App->Broker->GW
+ * @description Stop's watering immediately. The water plan will resume at
+ * the next point as setup.
+ */
+ public static final int CMD_IMMEDIATE_WATER_STOP = 7;
+
+ /**
+ * Command - 8. Fetch / Push Rainfall Data
+ *
+ * @direction GW->Broker->App
+ * @description Request for Rainfall data
+ * @direction App->Broker->GW
+ * @description Push of Rainfall data
+ */
+ public static final int CMD_RAINFALL_DATA = 8;
+
+ /**
+ * Command - 9. Notificaiton of watering has been skipped
+ *
+ * @direction GW->Broker->App
+ * @description Notification that a watering cycle has been skipped due to rainfall
+ */
+ public static final int CMD_NOTIFICATION_WATERING_SKIPPED = 9;
+
+ /**
+ * Command - 10. Alert Enablement / Disablement
+ *
+ * @direction App->Broker->GW
+ * @description Enable or disablement of particular monitoring alerts
+ */
+ public static final int CMD_ALERT_ENABLEMENT = 10;
+
+ /**
+ * Command - 11. Dismiss Alert
+ *
+ * @direction App->Broker->GW
+ * @description Dismisses the given alert
+ */
+ public static final int CMD_ALERT_DISMISS = 11;
+
+ /**
+ * Command - 12 Lockout state setup
+ *
+ * @direction App->Broker->GW
+ * @description Setup lockout state for manual On/Off button (for G15 and G25 models only)
+ */
+ public static final int CMD_LOCKOUT_STATE = 12;
+
+ /**
+ * Command - 13 Gateways Date & Time Sync Request
+ *
+ * @direction GW->Broker->App
+ * @description Request for the current date and time, for the Gateway to apply
+ */
+ public static final int CMD_DATETIME_SYNC = 13;
+
+ /**
+ * Command - 14 Read the Gateways Date & Time
+ *
+ * @direction App->Broker->Gw
+ * @description Fetch Gateway's local datetime
+ */
+ public static final int CMD_DATETIME_READ = 14;
+
+ /**
+ * Command - 15 Test wireless performance of end device
+ *
+ * @direction App->Broker->Gw
+ * @description Request a communications test between the Gateway and End Device
+ */
+ public static final int CMD_WIRELESS_CHECK = 15;
+
+ /**
+ * Command - 16 Get Gateway's configuration
+ *
+ * @direction App->Broker->Gw
+ * @description Request the current Gateway's configuration
+ */
+ public static final int CMD_GET_CONFIGURATION = 16;
+
+ /**
+ * Command - 17 Set Gateway's configuration
+ *
+ * @direction App->Broker->Gw
+ * @description Update the configuration for a device in the Gateway
+ */
+ public static final int CMD_SET_CONFIGURATION = 17;
+
+ /**
+ * Command - 18 Pause Water Plan
+ *
+ * @direction App->Broker->Gw
+ * @description Pause the Water Plan for the given duration
+ * (0.1 to 240 hours)
+ */
+ public static final int CMD_PAUSE_WATER_PLAN = 18;
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link TimeDataResp} defines a response that defines the time, date and weekday
+ * from the gateway.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class TimeDataResp extends HandshakeResp {
+
+ public TimeDataResp() {
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link ValidationError} represents a data payload validation error.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class ValidationError {
+ String variable = "";
+ String error = "";
+ Cause cause = Cause.BUG;
+
+ public ValidationError(String var, String err) {
+ this.variable = var;
+ this.error = err;
+ }
+
+ public ValidationError(String var, String err, Cause cause) {
+ this.variable = var;
+ this.error = err;
+ this.cause = cause;
+ }
+
+ public static enum Cause {
+ BUG,
+ USER
+ }
+
+ public Cause getCause() {
+ return this.cause;
+ }
+
+ public String toString() {
+ return this.variable + " " + this.cause;
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link DeviceCmdReq} is a payload representing the current status of the
+ * water timer.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class WaterMeterStatus extends GatewayDeviceResponse {
+
+ public WaterMeterStatus() {
+ }
+
+ @Override
+ public ResultStatus getRes() {
+ if (super.getRes() == ResultStatus.INVALID) {
+ return ResultStatus.RET_SUCCESS;
+ }
+ return super.getRes();
+ }
+
+ public static class DeviceStatusClassTypeAdapter implements JsonDeserializer<List<DeviceStatus>> {
+ public @Nullable List<DeviceStatus> deserialize(JsonElement json, Type typeOfT,
+ JsonDeserializationContext ctx) {
+ List<DeviceStatus> vals = new ArrayList<>();
+ if (json.isJsonArray()) {
+ for (JsonElement e : json.getAsJsonArray()) {
+ vals.add(ctx.deserialize(e, DeviceStatus.class));
+ }
+ } else if (json.isJsonObject()) {
+ vals.add(ctx.deserialize(json, DeviceStatus.class));
+ }
+ return vals;
+ }
+ }
+
+ /**
+ * Defines the device stat's for each device
+ */
+ @SerializedName("dev_stat")
+ @Expose
+ public List<DeviceStatus> deviceStatuses = new ArrayList<DeviceStatus>();
+
+ public static class DeviceStatus implements IPayloadValidator {
+ /**
+ * Defines the targetted device ID
+ */
+ @SerializedName("dev_id")
+ @Expose
+ public String deviceId = EMPTY_STRING;
+
+ /**
+ * Defines the currently active plan (Operating Mode)
+ */
+ @SerializedName("plan_mode")
+ @Expose
+ public @Nullable Integer planMode;
+
+ /**
+ * Defines the serial number of the currently active plan
+ */
+ @SerializedName("plan_sn")
+ @Expose
+ public int planSerialNo = DEFAULT_INT;
+
+ /**
+ * Defines if the water timer is connected to the Gateway
+ */
+ @SerializedName("is_rf_linked")
+ @Expose
+ public @Nullable Boolean isRfLinked;
+
+ /**
+ * Defines whether the flow meter is plugin
+ */
+ @SerializedName("is_flm_plugin")
+ @Expose
+ public @Nullable Boolean isFlmPlugin;
+
+ /**
+ * Water timer fall alert status
+ */
+ @SerializedName("is_fall")
+ @Expose
+ public @Nullable Boolean isFall;
+
+ /**
+ * Valve shut-down failure alert status
+ */
+ @SerializedName("is_broken")
+ @Expose
+ public @Nullable Boolean isBroken;
+
+ /**
+ * Water cut-off alert status
+ */
+ @SerializedName("is_cutoff")
+ @Expose
+ public @Nullable Boolean isCutoff;
+
+ /**
+ * Unusually high flow alert status
+ */
+ @SerializedName("is_leak")
+ @Expose
+ public @Nullable Boolean isLeak;
+
+ /**
+ * Unusually low flow alert status
+ */
+ @SerializedName("is_clog")
+ @Expose
+ public @Nullable Boolean isClog;
+
+ /**
+ * Water timer signal reception level
+ */
+ @SerializedName("signal")
+ @Expose
+ public @Nullable Integer signal;
+
+ /**
+ * Water timer battery level
+ */
+ @SerializedName("battery")
+ @Expose
+ public @Nullable Integer battery;
+
+ /**
+ * Defines the lock in operation
+ */
+ @SerializedName("child_lock")
+ @Expose
+ public @Nullable Integer childLock;
+
+ /**
+ * Is manual watering currently on
+ */
+ @SerializedName("is_manual_mode")
+ @Expose
+ public @Nullable Boolean isManualMode;
+
+ /**
+ * Is watering currently on
+ */
+ @SerializedName("is_watering")
+ @Expose
+ public @Nullable Boolean isWatering = false;
+
+ /**
+ * When the ECO mode is enabled, the watering duration is divided into multiple "on-off-on-off" segments.
+ * If is_final is true,it means current watering belongs to the last segment. If both is_watering and is_final
+ * are false,it means that the watering is currently suspended (i.e. in midst of the segments), and there are
+ * subsequent watering seqments to be executed.
+ */
+ @SerializedName("is_final")
+ @Expose
+ public @Nullable Boolean isFinal;
+
+ /**
+ * The duration of the current watering cycle in seconds
+ */
+ @SerializedName("total_duration")
+ @Expose
+ public @Nullable Integer totalDuration;
+
+ /**
+ * The remaining duration of the current watering cycle in seconds
+ */
+ @SerializedName("remain_duration")
+ @Expose
+ public @Nullable Integer remainDuration;
+
+ /**
+ * The failsafe duration of the current watering cycle in seconds
+ */
+ @SerializedName("failsafe_duration")
+ @Expose
+ public @Nullable Integer failsafeDuration;
+
+ /**
+ * The current water flow rate (LPN or GPM)
+ */
+ @SerializedName("speed")
+ @Expose
+ public @Nullable Double speed = 0.0d;
+
+ /**
+ * The accumulated volume of the current watering cycle (Litre or Gallon)
+ */
+ @SerializedName("volume")
+ @Expose
+ public @Nullable Double volume = 0.0d;
+
+ /**
+ * The volume limit of the current watering cycle (Litre or Gallon)
+ */
+ @SerializedName("volume_limit")
+ @Expose
+ public @Nullable Double volumeLimit = 0.0d;
+
+ public Collection<ValidationError> getValidationErrors() {
+ final Collection<ValidationError> errors = new ArrayList<>(0);
+
+ final Integer planModeRaw = planMode;
+ if (planModeRaw == null || planModeRaw < 1 || planModeRaw > OP_MODE_DESC.length) {
+ errors.add(new ValidationError("planMode", "is not in range 1 -> " + OP_MODE_DESC.length));
+ }
+
+ final Integer signalRaw = signal;
+ if (signalRaw == null || signalRaw < 0 || signalRaw > 100) {
+ errors.add(new ValidationError("signal", "is not in range 0 -> 100"));
+ }
+ final Integer batteryRaw = battery;
+ if (batteryRaw == null || batteryRaw < 0 || batteryRaw > 100) {
+ errors.add(new ValidationError("battery", "is not in range 0 -> 100"));
+ }
+ if (planSerialNo == DEFAULT_INT) {
+ errors.add(new ValidationError("plan_sn", "is invalid"));
+ }
+ final Integer childLockRaw = childLock;
+ if (childLockRaw == null || childLockRaw < LockReq.LOCK_UNLOCKED || childLockRaw > LockReq.LOCK_FULL) {
+ errors.add(new ValidationError("child_lock",
+ "is not in range " + LockReq.LOCK_UNLOCKED + " -> " + LockReq.LOCK_FULL));
+ }
+ if (!DEVICE_ID_PATTERN.matcher(deviceId).matches()) {
+ errors.add(new ValidationError("dev_id", "is not in the expected format"));
+ }
+
+ return errors;
+ }
+ }
+
+ public Collection<ValidationError> getValidationErrors() {
+ return EMPTY_COLLECTION;
+ }
+
+ public static final int OP_MODE_INSTANT = 1;
+
+ public static final int OP_MODE_CALENDAR = 2;
+
+ public static final int OP_MODE_WEEK_TIMER = 3;
+
+ public static final int OP_MODE_ODD_EVEN = 4;
+
+ public static final int OP_MODE_INTERVAL = 5;
+
+ public static final int OP_MODE_MONTH = 6;
+
+ public static final String[] OP_MODE_DESC = new String[] { "Instant Mode", "Calendar Mode", "7 Day Mode",
+ "Odd-Even Mode", "Interval Mode", "Month Mode" };
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import static org.openhab.binding.linktap.protocol.frames.ValidationError.Cause.BUG;
+
+import java.util.Collection;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link WateringSkippedNotification} defines the request to dismiss alerts from a given device.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class WateringSkippedNotification extends DeviceCmdReq {
+
+ public WateringSkippedNotification() {
+ }
+
+ /**
+ * Defines the past rainfall [0] and future rainfull [1] measurements in mm
+ */
+ @SerializedName("rain")
+ @Expose
+ public double[] rainfallData = new double[] { 0.0, 0.0 };
+
+ public void setPastRainfall(final double pastRainMM) {
+ rainfallData[0] = pastRainMM;
+ }
+
+ public double getPastRainfall() {
+ return rainfallData[0];
+ }
+
+ public void setFutureRainfall(final double futureRainMM) {
+ rainfallData[1] = futureRainMM;
+ }
+
+ public double getFutureRainfall() {
+ return rainfallData[1];
+ }
+
+ public Collection<ValidationError> getValidationErrors() {
+ final Collection<ValidationError> errors = super.getValidationErrors();
+
+ if (rainfallData.length != 2) {
+ errors.add(new ValidationError("rain", "invalid number of entries", BUG));
+ }
+
+ return errors;
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import java.util.Collection;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.Expose;
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link WirelessTestResp} defines the wireless test result data response
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class WirelessTestResp extends EndpointDeviceResponse {
+
+ public WirelessTestResp() {
+ }
+
+ /**
+ * Defines true if the last packet has been transmitted and
+ * therefore the test is complete
+ */
+ @SerializedName("final")
+ @Expose
+ public boolean testComplete = false;
+
+ /**
+ * Defines how many pings have been sent to the endpoint device
+ */
+ @SerializedName("ping")
+ @Expose
+ public int pingCount = DEFAULT_INT;
+
+ /**
+ * Defines how many pongs have been received from the endpoint device
+ */
+ @SerializedName("pong")
+ @Expose
+ public int pongCount = DEFAULT_INT;
+
+ public Collection<ValidationError> getValidationErrors() {
+ final Collection<ValidationError> errors = super.getValidationErrors();
+
+ if (pingCount == DEFAULT_INT) {
+ errors.add(new ValidationError("ping", "count is missing"));
+ }
+
+ if (pongCount == DEFAULT_INT) {
+ errors.add(new ValidationError("pong", "count is missing"));
+ }
+
+ if (!DEVICE_ID_PATTERN.matcher(deviceId).matches()) {
+ errors.add(new ValidationError("dev_id", "is not in the expected format"));
+ }
+
+ return errors;
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.http;
+
+import java.io.Serial;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.linktap.protocol.frames.GatewayDeviceResponse;
+
+/**
+ * The {@link CommandNotSupportedException} should be thrown when the endpoint being communicated with
+ * does not appear to be a Tap Link Gateway device.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class CommandNotSupportedException extends I18Exception {
+ @Serial
+ private static final long serialVersionUID = -7784829325604153947L;
+
+ public CommandNotSupportedException() {
+ super();
+ }
+
+ public CommandNotSupportedException(final String message) {
+ super(message);
+ }
+
+ public CommandNotSupportedException(final Throwable cause) {
+ super(cause);
+ }
+
+ public CommandNotSupportedException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+
+ public CommandNotSupportedException(final GatewayDeviceResponse.ResultStatus rs) {
+ super(rs.getDesc());
+ this.i18Key = rs.getI18Key();
+ }
+
+ public String getI18Key() {
+ return getI18Key("exception-cmd-not-supported-exception");
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.http;
+
+import java.io.Serial;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.linktap.protocol.frames.GatewayDeviceResponse;
+
+/**
+ * The {@link DeviceIdException} should be thrown when the endpoint being communicated with
+ * does not appear to be a Tap Link Gateway device.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class DeviceIdException extends I18Exception {
+ @Serial
+ private static final long serialVersionUID = -7786449325604153947L;
+
+ // case RET_DEVICE_ID_ERROR:
+ // case RET_DEVICE_NOT_FOUND:
+
+ public DeviceIdException() {
+ super();
+ }
+
+ public DeviceIdException(final String message) {
+ super(message);
+ }
+
+ public DeviceIdException(final Throwable cause) {
+ super(cause);
+ }
+
+ public DeviceIdException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+
+ public DeviceIdException(final GatewayDeviceResponse.ResultStatus rs) {
+ super(rs.getDesc());
+ this.i18Key = rs.getI18Key();
+ }
+
+ public String getI18Key() {
+ return getI18Key("exception.device-id-exception");
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.http;
+
+import java.io.Serial;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.linktap.protocol.frames.GatewayDeviceResponse;
+
+/**
+ * The {@link GatewayIdException} should be thrown when the endpoint being communicated with
+ * does not appear to be a Tap Link Gateway device.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class GatewayIdException extends I18Exception {
+ @Serial
+ private static final long serialVersionUID = -7786449325604153947L;
+
+ // case RET_DEVICE_ID_ERROR:
+ // case RET_DEVICE_NOT_FOUND:
+
+ public GatewayIdException() {
+ super();
+ }
+
+ public GatewayIdException(final String message) {
+ super(message);
+ }
+
+ public GatewayIdException(final Throwable cause) {
+ super(cause);
+ }
+
+ public GatewayIdException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+
+ public GatewayIdException(final GatewayDeviceResponse.ResultStatus rs) {
+ super(rs.getDesc());
+ this.i18Key = rs.getI18Key();
+ }
+
+ public String getI18Key() {
+ return getI18Key("exception.gw-id-exception");
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.http;
+
+import java.io.Serial;
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link I18Exception} is a abstract class for exceptions that support
+ * i18key functionality.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public abstract class I18Exception extends Exception {
+
+ @Serial
+ private static final long serialVersionUID = -7784829349743963947L;
+
+ protected String i18Key = "";
+
+ public I18Exception() {
+ super();
+ }
+
+ public I18Exception(final String message) {
+ super(message);
+ }
+
+ public I18Exception(final Throwable cause) {
+ super(cause);
+ }
+
+ public I18Exception(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+
+ public abstract String getI18Key();
+
+ public String getI18Key(final String defaultI18) {
+ if (!i18Key.isBlank()) {
+ return i18Key;
+ }
+ return Objects.requireNonNullElse(getMessage(), defaultI18);
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.http;
+
+import java.io.Serial;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.linktap.protocol.frames.GatewayDeviceResponse;
+
+/**
+ * The {@link InvalidParameterException} should be thrown when the endpoint being communicated with
+ * does not appear to be a Tap Link Gateway device.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class InvalidParameterException extends I18Exception {
+ @Serial
+ private static final long serialVersionUID = -7784829499604153947L;
+
+ public InvalidParameterException() {
+ super();
+ }
+
+ public InvalidParameterException(final String message) {
+ super(message);
+ }
+
+ public InvalidParameterException(final Throwable cause) {
+ super(cause);
+ }
+
+ public InvalidParameterException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+
+ public InvalidParameterException(final GatewayDeviceResponse.ResultStatus rs) {
+ super(rs.getDesc());
+ this.i18Key = rs.getI18Key();
+ }
+
+ public String getI18Key() {
+ return getI18Key("exception.invalid-parameter-exception");
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.http;
+
+import java.io.Serial;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link LinkTapException} is a class for general exceptions that support
+ * i18key functionality.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class LinkTapException extends I18Exception {
+
+ @Serial
+ private static final long serialVersionUID = -7739358310944502365L;
+
+ protected String i18Key = "";
+
+ public LinkTapException() {
+ super();
+ }
+
+ public LinkTapException(final String message) {
+ super(message);
+ }
+
+ public LinkTapException(final Throwable cause) {
+ super(cause);
+ }
+
+ public LinkTapException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+
+ @Override
+ public String getI18Key() {
+ return getI18Key("exception.unexpected-exception");
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.http;
+
+import java.io.Serial;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link NotTapLinkGatewayException} should be thrown when the endpoint being communicated with
+ * does not appear to be a Tap Link Gateway device.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class NotTapLinkGatewayException extends I18Exception {
+ @Serial
+ private static final long serialVersionUID = -7786449325604153487L;
+
+ public NotTapLinkGatewayException() {
+ super();
+ }
+
+ public NotTapLinkGatewayException(final String message) {
+ super(message);
+ }
+
+ public NotTapLinkGatewayException(final Throwable cause) {
+ super(cause);
+ }
+
+ public NotTapLinkGatewayException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+
+ public NotTapLinkGatewayException(final String message, final String i18key) {
+ super(message);
+ this.i18Key = i18key;
+ }
+
+ public NotTapLinkGatewayException(final NotTapLinkGatewapExecptionDefinitions definition) {
+ this(definition.description, definition.i18Key);
+ }
+
+ public enum NotTapLinkGatewapExecptionDefinitions {
+
+ /**
+ * HEADERS_MISSING
+ */
+ HEADERS_MISSING("Missing header markers", "exception.not-gw.missing-headers"),
+
+ /**
+ * MISSING_API_TITLE
+ */
+ MISSING_API_TITLE("Not a LinkTap API response", "exception.not-gw.missing-api-title"),
+
+ /**
+ * MISSING_SERVER_TITLE
+ */
+ MISSING_SERVER_TITLE("Not a LinkTap response", "exception.not-gw.missing-server-title"),
+
+ /**
+ * UNEXPECTED_STATUS_CODE
+ */
+ UNEXPECTED_STATUS_CODE("Unexpected status code response", "exception.not-gw.unexpected-status-code"),
+
+ /**
+ * UNEXPECTED_HTTPS
+ */
+ UNEXPECTED_HTTPS("Unexpected protocol", "exception.not-gw.unexpected-protocol");
+
+ private final String description;
+ private final String i18Key;
+
+ private NotTapLinkGatewapExecptionDefinitions(final String description, final String i18key) {
+ this.description = description;
+ this.i18Key = i18key;
+ }
+
+ public String getI18Key() {
+ return i18Key;
+ }
+
+ public String getDesc() {
+ return description;
+ }
+
+ @Override
+ public String toString() {
+ return description;
+ }
+ }
+
+ public String getI18Key() {
+ return getI18Key("exception.not-tap-link-gw");
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.http;
+
+import java.io.Serial;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link TransientCommunicationIssueException} should be thrown when the endpoint being communicated with
+ * does not appear to be a Tap Link Gateway device.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class TransientCommunicationIssueException extends I18Exception {
+ @Serial
+ private static final long serialVersionUID = -7786449325604143287L;
+
+ public TransientCommunicationIssueException() {
+ super();
+ }
+
+ public TransientCommunicationIssueException(final String message, final String i18key) {
+ super(message);
+ this.i18Key = i18key;
+ }
+
+ public TransientCommunicationIssueException(final TransientExecptionDefinitions definition) {
+ this(definition.description, definition.i18Key);
+ }
+
+ public TransientCommunicationIssueException(final Throwable cause) {
+ super(cause);
+ }
+
+ public TransientCommunicationIssueException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+
+ public enum TransientExecptionDefinitions {
+
+ /**
+ * HOST_UNREACHABLE
+ */
+ HOST_UNREACHABLE("Could not connect", "exception.could-not-connect"),
+
+ /**
+ * HOST_NOT_RESOLVED
+ */
+ HOST_NOT_RESOLVED("Could not resolve IP address", "exception.could-not-resolve"),
+
+ /**
+ * COMMUNICATIONS_LOST
+ */
+ COMMUNICATIONS_LOST("Communications Lost", "exception.communications-lost");
+
+ private final String description;
+ private final String i18Key;
+
+ private TransientExecptionDefinitions(final String description, final String i18key) {
+ this.description = description;
+ this.i18Key = i18key;
+ }
+
+ public String getI18Key() {
+ return i18Key;
+ }
+
+ public String getDesc() {
+ return description;
+ }
+
+ @Override
+ public String toString() {
+ return description;
+ }
+ }
+
+ public String getI18Key() {
+ return getI18Key("exception.gw-id-exception");
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.http;
+
+import static org.openhab.binding.linktap.internal.LinkTapBindingConstants.*;
+import static org.openhab.binding.linktap.protocol.http.NotTapLinkGatewayException.NotTapLinkGatewapExecptionDefinitions.*;
+import static org.openhab.binding.linktap.protocol.http.TransientCommunicationIssueException.TransientExecptionDefinitions.*;
+
+import java.net.HttpURLConnection;
+import java.net.InetAddress;
+import java.net.SocketTimeoutException;
+import java.net.URLDecoder;
+import java.net.URLEncoder;
+import java.net.UnknownHostException;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import javax.net.ssl.SSLHandshakeException;
+import javax.ws.rs.HttpMethod;
+import javax.ws.rs.core.MediaType;
+
+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.FormContentProvider;
+import org.eclipse.jetty.client.util.StringContentProvider;
+import org.eclipse.jetty.http.HttpFields;
+import org.eclipse.jetty.http.HttpHeader;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+import org.openhab.binding.linktap.internal.Firmware;
+import org.openhab.binding.linktap.internal.Utils;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.i18n.TranslationProvider;
+import org.osgi.framework.Bundle;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link WebServerApi} defines interactions with the web server interface.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public final class WebServerApi {
+
+ public static final String URI_SCHEME = "http";
+ public static final String URI_HOST_PREFIX = URI_SCHEME + "://";
+
+ /**
+ * Headers
+ */
+ public static final String HEADER_SERVER = "Server";
+ public static final String HEADER_GW_SERVER_NAME = "LinkTap Gateway";
+
+ /**
+ * HTML title field mappings to use cases
+ */
+ private static final String TITLE_API_RESPONSE = "api";
+ private static final String TITLE_API_CONFIG_PAGE = "LinkTap Gateway";
+ private static final String TITLE_API_LOGIN_PAGE = "LinkTap Gateway Login";
+
+ /**
+ * Field names for form submission API's
+ */
+ private static final String FIELD_ADMIN_USER = "admin";
+ private static final String FIELD_ADMIN_USER_PWD = "adminpwd";
+
+ private static final int REQ_TIMEOUT_SECONDS = 3;
+ private static final WebServerApi INSTANCE = new WebServerApi();
+ private static final String REQ_HDR_APPLICATION_JSON = new MediaType("application", "json", "UTF-8").toString();
+ private final Logger logger = LoggerFactory.getLogger(WebServerApi.class);
+
+ private @NonNullByDefault({}) HttpClient httpClient;
+ private @Nullable TranslationProvider translationProvider;
+ private @Nullable LocaleProvider localeProvider;
+ private @Nullable Bundle bundle;
+
+ private WebServerApi() {
+ }
+
+ public static WebServerApi getInstance() {
+ return INSTANCE;
+ }
+
+ public void setTranslationProviderInfo(TranslationProvider translationProvider, LocaleProvider localeProvider,
+ Bundle bundle) {
+ this.bundle = bundle;
+ this.localeProvider = localeProvider;
+ this.translationProvider = translationProvider;
+ }
+
+ public String getLocalizedText(String key, @Nullable Object @Nullable... arguments) {
+ TranslationProvider translationProv = translationProvider;
+ LocaleProvider localeProv = localeProvider;
+ if (translationProv == null || localeProv == null) {
+ return key;
+ }
+ String result = translationProv.getText(bundle, key, key, localeProv.getLocale(), arguments);
+ return Objects.nonNull(result) ? result : key;
+ }
+
+ /**
+ * Sets the httpClient object to be used for API calls to LinkTap.
+ *
+ * @param httpClient the client to be used.
+ */
+ public void setHttpClient(@Nullable HttpClient httpClient) {
+ if (httpClient != null) {
+ this.httpClient = httpClient;
+ }
+ }
+
+ public Map<String, String> getBridgeProperities(final String hostname)
+ throws LinkTapException, NotTapLinkGatewayException, TransientCommunicationIssueException {
+ try {
+ final Request request = httpClient.newRequest(URI_HOST_PREFIX + hostname).method(HttpMethod.GET);
+ final ContentResponse cr = request.timeout(REQ_TIMEOUT_SECONDS, TimeUnit.SECONDS).send();
+ if (HttpURLConnection.HTTP_OK != cr.getStatus()) {
+ throw new NotTapLinkGatewayException(UNEXPECTED_STATUS_CODE);
+ }
+ validateHeaders(cr.getHeaders());
+ final String responseData = cr.getContentAsString();
+ final Document doc = Jsoup.parse(responseData);
+
+ switch (doc.title()) {
+ case TITLE_API_CONFIG_PAGE:
+ break;
+ case TITLE_API_LOGIN_PAGE:
+ return Map.of();
+ default:
+ throw new NotTapLinkGatewayException(MISSING_SERVER_TITLE);
+ }
+ final Map<String, String> deviceProps = getMetadataProperties(doc);
+ Firmware firmware = new Firmware(deviceProps.get(BRIDGE_PROP_GW_VER));
+ if (firmware.supportsMDNS()) {
+ getMdnsEnableArgs(doc);
+ } else {
+ logger.debug("Firmware revision does not include mDNS support");
+ }
+ return deviceProps;
+
+ } catch (InterruptedException e) {
+ return Map.of();
+ } catch (TimeoutException e) {
+ throw new TransientCommunicationIssueException(COMMUNICATIONS_LOST);
+ } catch (ExecutionException e) {
+ final Throwable t = e.getCause();
+ if (t instanceof UnknownHostException || t instanceof SocketTimeoutException) {
+ throw new TransientCommunicationIssueException(HOST_UNREACHABLE);
+ } else if (t instanceof SSLHandshakeException) {
+ throw new NotTapLinkGatewayException(UNEXPECTED_HTTPS);
+ } else {
+ logger.warn("{}", getLocalizedText("ExecutionException -> {}", Utils.getMessage(e)));
+ }
+ throw new LinkTapException(getLocalizedText("exception.unexpected-failure", Utils.getMessage(e)));
+ }
+ }
+
+ /**
+ * Extract the common properties for all devices, from the given meta-data of a device.
+ *
+ * @param doc the html document returns from the potential Gateway device
+ * @return Map of common props
+ */
+ private Map<String, String> getMetadataProperties(final Document doc) {
+ final Map<String, String> newProps = new HashMap<>(7);
+
+ /*
+ * Extract elements based on td location using the text markers
+ */
+ String firmwareVer = "?";
+ String hwModel = "?";
+ String id = "?";
+ String macAddr = "?";
+
+ final org.jsoup.select.Elements tdEntries = doc.getElementsByTag("td");
+ for (int i = 0; i < tdEntries.size(); ++i) {
+ if (tdEntries.get(i).hasText()) {
+ switch (tdEntries.get(i).text()) {
+ case "Firmware version":
+ firmwareVer = tdEntries.get(i + 1).text();
+ i++;
+ break;
+ case "Model":
+ hwModel = tdEntries.get(i + 1).text();
+ i++;
+ break;
+ case "ID":
+ id = tdEntries.get(i + 1).text();
+ i++;
+ break;
+ case "MAC address":
+ macAddr = tdEntries.get(i + 1).text();
+ i++;
+ break;
+ }
+ }
+ }
+
+ newProps.put(BRIDGE_PROP_GW_ID, id.split("[-]")[0]);
+ newProps.put(BRIDGE_PROP_GW_VER, firmwareVer.split("[_]")[0]);
+ newProps.put(BRIDGE_PROP_MAC_ADDR, macAddr);
+ newProps.put(BRIDGE_PROP_HW_MODEL, hwModel);
+
+ /*
+ * Extract elements based on name markers and attributes
+ */
+ final boolean httpApiEnabled = doc.getElementsByAttributeValue("name", "htapi").hasAttr("checked");
+ final String httpApiEndpoint = doc.getElementsByAttributeValue("name", "URL").attr("value");
+
+ newProps.put(BRIDGE_PROP_HTTP_API_ENABLED, String.valueOf(httpApiEnabled));
+ newProps.put(BRIDGE_PROP_HTTP_API_EP, httpApiEndpoint);
+
+ Optional<Element> vunitSelections = doc.getElementsByAttributeValue("name", "vunit").stream()
+ .filter(x -> x.hasAttr("checked")).findFirst();
+ if (vunitSelections.isPresent()) {
+ switch (vunitSelections.get().attr("value")) {
+ case "0":
+ newProps.put(BRIDGE_PROP_VOL_UNIT, "L");
+ break;
+ case "1":
+ newProps.put(BRIDGE_PROP_VOL_UNIT, "gal");
+ break;
+ }
+ }
+
+ return newProps;
+ }
+
+ public Optional<Element> getSection(final Document doc, final String title) {
+ final Elements thead = doc.getElementsByTag("thead");
+ Optional<Element> element = thead.stream()
+ .filter(x -> x.hasText() && x.text().toLowerCase().contains(title.toLowerCase())).findFirst();
+ if (element.isPresent()) {
+ return Optional.of(element.get().parent());
+ }
+ return Optional.empty();
+ }
+
+ public String getUriInputArg(final Element el) {
+ final StringBuilder sb = new StringBuilder();
+ switch (el.attr("type")) {
+ case "checkbox":
+ sb.append(el.attr("name"));
+ sb.append("=");
+ if (el.hasAttr("checked")) {
+ sb.append(el.attr("value"));
+ } else {
+ sb.append("0");
+ }
+ break;
+ case "radio":
+ if (el.hasAttr("checked")) {
+ sb.append(el.attr("name"));
+ sb.append("=");
+ sb.append(el.attr("value"));
+ }
+ break;
+ case "text":
+ sb.append(el.attr("name"));
+ sb.append("=");
+ sb.append(URLDecoder.decode(el.attr("value"), StandardCharsets.UTF_8));
+ }
+ return sb.toString();
+ }
+
+ public String getMdnsEnableArgs(final Document doc) {
+ final Optional<Element> miscSection = getSection(doc, "Misc settings");
+ StringBuilder sb = new StringBuilder();
+
+ if (!miscSection.isPresent()) {
+ return sb.toString();
+ }
+ final Elements inputs = miscSection.get().getElementsByTag("input");
+ for (int i = 0; i < inputs.size(); ++i) {
+ final String val = getUriInputArg(inputs.get(i));
+ if (!val.isEmpty() && !sb.isEmpty()) {
+ sb.append("&");
+ }
+ sb.append(val);
+ }
+ // Change the mdns flag to true
+ {
+ final int mdnsIdx = sb.indexOf("mdns=0");
+ if (mdnsIdx != -1) {
+ sb.replace(mdnsIdx, mdnsIdx + 6, "mdns=1");
+ return sb.toString();
+ }
+ }
+
+ return "";
+ }
+
+ public String getLocalHttpApiArgs(final Document doc, final Optional<String> targetServerOpt,
+ final Optional<Boolean> wrapHtmlDisable) {
+ final Optional<Element> localHttpApiSection = getSection(doc, "Local HTTP API settings");
+ StringBuilder sb = new StringBuilder();
+
+ if (!localHttpApiSection.isPresent()) {
+ return sb.toString();
+ }
+ final Elements inputs = localHttpApiSection.get().getElementsByTag("input");
+ for (int i = 0; i < inputs.size(); ++i) {
+ final String val = getUriInputArg(inputs.get(i));
+ if (!val.isEmpty() && !sb.isEmpty()) {
+ sb.append("&");
+ }
+ sb.append(val);
+ }
+
+ boolean updatedUri = false;
+ int enableApiIdx = -1;
+
+ if (targetServerOpt.isPresent()) {
+ String targetServer = targetServerOpt.get();
+ // Change the enable Local HTTP API flag to true
+ enableApiIdx = sb.indexOf("htapi=0");
+ if (enableApiIdx != -1) {
+ sb.replace(enableApiIdx, enableApiIdx + 7, "htapi=1");
+ }
+
+ final int urlApiMarker = sb.indexOf("URL=");
+ if (urlApiMarker != -1) {
+ final int nextArg = sb.indexOf("&", urlApiMarker);
+ String urlArg = (nextArg == -1) ? sb.substring(urlApiMarker + 4)
+ : sb.substring(urlApiMarker + 4, nextArg);
+ logger.trace("Found existing HTTP URL Server : {}", urlArg);
+ if (!urlArg.equals(targetServer)) {
+ updatedUri = true;
+ sb.replace(urlApiMarker, urlApiMarker + urlArg.length() + 4,
+ "URL=" + URLEncoder.encode(targetServer, StandardCharsets.UTF_8));
+ }
+ }
+ }
+
+ int wgrhIdx = -1;
+ if (wrapHtmlDisable.isPresent() && wrapHtmlDisable.get()) {
+ // Change the wgrhIdx flag to true
+ {
+ wgrhIdx = sb.indexOf("wgrh=1");
+ if (wgrhIdx != -1) {
+ sb.replace(wgrhIdx, wgrhIdx + 6, "wgrh=0");
+ return sb.toString();
+ }
+ }
+ }
+
+ if (wgrhIdx != -1 || enableApiIdx != -1 || updatedUri) {
+ return sb.toString();
+ }
+
+ return "";
+ }
+
+ public boolean configureBridge(final @Nullable String hostname, final Optional<Boolean> mdnsEnable,
+ final Optional<Boolean> nonHtmlEnable, final Optional<String> localServer)
+ throws InterruptedException, NotTapLinkGatewayException, TransientCommunicationIssueException {
+ try {
+ if (hostname == null) {
+ throw new TransientCommunicationIssueException(HOST_NOT_RESOLVED);
+ }
+ final String targetHost = URI_HOST_PREFIX + hostname;
+ final Request request = httpClient.newRequest(targetHost).method(HttpMethod.GET);
+ final ContentResponse cr = request.timeout(REQ_TIMEOUT_SECONDS, TimeUnit.SECONDS).send();
+ if (HttpURLConnection.HTTP_OK != cr.getStatus()) {
+ throw new NotTapLinkGatewayException(UNEXPECTED_STATUS_CODE);
+ }
+ validateHeaders(cr.getHeaders());
+ final String responseData = cr.getContentAsString();
+ final Document doc = Jsoup.parse(responseData);
+
+ switch (doc.title()) {
+ case TITLE_API_CONFIG_PAGE:
+ break;
+ case TITLE_API_LOGIN_PAGE:
+ return false;
+ default:
+ throw new NotTapLinkGatewayException(MISSING_SERVER_TITLE);
+ }
+ // Send the GET request to configure mdns if it's not enabled
+ boolean rebootReq = false;
+ if (mdnsEnable.isPresent() && mdnsEnable.get()) {
+ logger.trace("Enabling mdns server on gateway");
+ String mdnsEnableReqStr = getMdnsEnableArgs(doc);
+ if (!mdnsEnableReqStr.isEmpty()) {
+ logger.debug("Updating mdns server settings on gateway");
+ final Request mdnsRequest = httpClient
+ .newRequest(targetHost + "/index.shtml?flag=4&" + mdnsEnableReqStr).method(HttpMethod.GET);
+ final ContentResponse mdnsCr = mdnsRequest.timeout(REQ_TIMEOUT_SECONDS, TimeUnit.SECONDS).send();
+ if (HttpURLConnection.HTTP_OK != mdnsCr.getStatus()) {
+ throw new NotTapLinkGatewayException(UNEXPECTED_STATUS_CODE);
+ }
+ rebootReq = true;
+ }
+ }
+
+ if (localServer.isPresent() && !localServer.get().isBlank()
+ || nonHtmlEnable.isPresent() && nonHtmlEnable.get()) {
+ if (localServer.isPresent() && !localServer.get().isBlank()) {
+ logger.trace("Setting Local HTTP Api on gateway");
+ }
+ if (nonHtmlEnable.isPresent() && nonHtmlEnable.get()) {
+ logger.trace("Enabling efficient non HTML communications on gateway");
+ }
+
+ String localHttpApiReqStr = this.getLocalHttpApiArgs(doc, localServer, nonHtmlEnable);
+ if (!localHttpApiReqStr.isEmpty()) {
+ logger.debug("Updating Local HTTP API server settings on gateway");
+ final Request lhttpApiRequest = httpClient
+ .newRequest(targetHost + "/index.shtml?flag=5&" + localHttpApiReqStr)
+ .method(HttpMethod.GET);
+ final ContentResponse mdnsCr = lhttpApiRequest.timeout(REQ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .send();
+ if (HttpURLConnection.HTTP_OK != mdnsCr.getStatus()) {
+ throw new NotTapLinkGatewayException(UNEXPECTED_STATUS_CODE);
+ }
+ rebootReq = true;
+ }
+ }
+
+ if (rebootReq) {
+ logger.debug("Rebooting gateway to apply new settings");
+ final Request restartReq = httpClient.newRequest(targetHost + "/index.shtml?flag=0")
+ .method(HttpMethod.GET);
+ final ContentResponse mdnsCr = restartReq.timeout(REQ_TIMEOUT_SECONDS, TimeUnit.SECONDS).send();
+ if (HttpURLConnection.HTTP_OK != mdnsCr.getStatus()) {
+ throw new NotTapLinkGatewayException(UNEXPECTED_STATUS_CODE);
+ }
+ }
+
+ return rebootReq;
+
+ } catch (TimeoutException e) {
+ throw new TransientCommunicationIssueException(HOST_UNREACHABLE);
+ } catch (ExecutionException e) {
+ final Throwable t = e.getCause();
+ if (t instanceof UnknownHostException) {
+ throw new TransientCommunicationIssueException(HOST_NOT_RESOLVED);
+ } else if (t instanceof SocketTimeoutException) {
+ throw new TransientCommunicationIssueException(HOST_UNREACHABLE);
+ } else if (t instanceof SSLHandshakeException) {
+ throw new NotTapLinkGatewayException(UNEXPECTED_HTTPS);
+ } else {
+ logger.warn("{}", getLocalizedText("ExecutionException -> {}", Utils.getMessage(e)));
+ }
+ throw new NotTapLinkGatewayException(getLocalizedText("exception.unexpected-failure", Utils.getMessage(t)));
+ }
+ }
+
+ public boolean unlockWebInterface(final String hostname, final String username, final String password)
+ throws LinkTapException, NotTapLinkGatewayException, TransientCommunicationIssueException {
+ try {
+ org.eclipse.jetty.util.Fields fields = new org.eclipse.jetty.util.Fields();
+ fields.put(FIELD_ADMIN_USER, username);
+ fields.put(FIELD_ADMIN_USER_PWD, password);
+ final Request request = httpClient.newRequest(URI_HOST_PREFIX + hostname + "/login.shtml")
+ .method(HttpMethod.POST).content(new FormContentProvider(fields));
+ final ContentResponse cr = request.timeout(REQ_TIMEOUT_SECONDS, TimeUnit.SECONDS).send();
+ if (HttpURLConnection.HTTP_OK != cr.getStatus()) {
+ throw new NotTapLinkGatewayException(UNEXPECTED_STATUS_CODE);
+ }
+ validateHeaders(cr.getHeaders());
+ return !getBridgeProperities(hostname).isEmpty();
+ } catch (InterruptedException e) {
+ return false;
+ } catch (TimeoutException e) {
+ throw new TransientCommunicationIssueException(HOST_UNREACHABLE);
+ } catch (ExecutionException e) {
+ final Throwable t = e.getCause();
+ if (t instanceof UnknownHostException) {
+ throw new TransientCommunicationIssueException(HOST_NOT_RESOLVED);
+ } else if (t instanceof SocketTimeoutException) {
+ throw new TransientCommunicationIssueException(HOST_UNREACHABLE);
+ } else if (t instanceof SSLHandshakeException) {
+ throw new NotTapLinkGatewayException(UNEXPECTED_HTTPS);
+ } else {
+ logger.warn("{}", getLocalizedText("ExecutionException -> {}", Utils.getMessage(e)));
+ }
+ throw new NotTapLinkGatewayException(getLocalizedText("exception.unexpected-failure", Utils.getMessage(e)));
+ }
+ }
+
+ /**
+ * Returns whether a response from the HTTP endpoint reached, appears to have the correct
+ * header markers for a Link Tap Gateway device.
+ *
+ * @param headers the http headers from the response to be checked
+ * @throws NotTapLinkGatewayException if the response does not appear to be from a Link Tap Gateway
+ */
+ private void validateHeaders(final HttpFields headers) throws NotTapLinkGatewayException {
+ if (!headers.contains(HEADER_SERVER, HEADER_GW_SERVER_NAME)) {
+ throw new NotTapLinkGatewayException(HEADERS_MISSING);
+ }
+ }
+
+ public String sendRequest(final String hostname, final String requestBody)
+ throws NotTapLinkGatewayException, TransientCommunicationIssueException {
+ try {
+ final InetAddress address = InetAddress.getByName(hostname);
+ logger.trace("API Endpoint: {}", URI_HOST_PREFIX + address.getHostAddress() + "/api.shtml");
+ final Request request = httpClient.POST(URI_HOST_PREFIX + address.getHostAddress() + "/api.shtml");
+ request.content(new StringContentProvider(requestBody), REQ_HDR_APPLICATION_JSON);
+
+ final ContentResponse cr = request.timeout(REQ_TIMEOUT_SECONDS, TimeUnit.SECONDS).send();
+ if (HttpURLConnection.HTTP_OK != cr.getStatus()) {
+ throw new NotTapLinkGatewayException(UNEXPECTED_STATUS_CODE);
+ }
+
+ final HttpFields headers = cr.getHeaders();
+ validateHeaders(headers);
+
+ String responseData = cr.getContentAsString();
+ final String contentType = headers.get(HttpHeader.CONTENT_TYPE);
+
+ // If content type is test/html its wrapped in HTML (Old standard)
+ // If content type is application/json it's a raw compact response (More efficient new standard)
+ switch (contentType) {
+ case "text/html":
+ final Document doc = Jsoup.parse(responseData);
+ final String docTitle = doc.title();
+ if (!docTitle.equals(TITLE_API_RESPONSE)) {
+ throw new NotTapLinkGatewayException(MISSING_API_TITLE);
+ }
+ responseData = doc.body().text();
+ break;
+ case "application/json":
+ // Do nothing - the raw content is the response
+ break;
+ default:
+ responseData = "";
+ }
+
+ return responseData;
+ } catch (InterruptedException e) {
+ return "";
+ } catch (TimeoutException e) {
+ throw new TransientCommunicationIssueException(HOST_UNREACHABLE);
+ } catch (UnknownHostException e) {
+ throw new TransientCommunicationIssueException(HOST_NOT_RESOLVED);
+ } catch (ExecutionException e) {
+ final Throwable t = e.getCause();
+ if (t instanceof UnknownHostException) {
+ throw new TransientCommunicationIssueException(HOST_NOT_RESOLVED);
+ } else if (t instanceof SSLHandshakeException) {
+ throw new NotTapLinkGatewayException(UNEXPECTED_HTTPS);
+ } else {
+ throw new TransientCommunicationIssueException(HOST_UNREACHABLE);
+ }
+ }
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.servers;
+
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.EMPTY_STRING;
+
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.util.Objects;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.ws.rs.core.MediaType;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.http.HttpStatus;
+import org.openhab.binding.linktap.internal.LinkTapBindingConstants;
+import org.openhab.binding.linktap.internal.TransactionProcessor;
+import org.openhab.binding.linktap.internal.Utils;
+import org.openhab.binding.linktap.protocol.frames.TLGatewayFrame;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.i18n.TranslationProvider;
+import org.osgi.framework.Bundle;
+import org.osgi.service.http.HttpService;
+import org.osgi.service.http.NamespaceException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link BindingServlet} defines the request to enable or disable alerts from a given device.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class BindingServlet extends HttpServlet {
+
+ public static final BindingServlet INSTANCE = new BindingServlet();
+ public static final String SERVLET_URL_WITHOUT_ROOT = "linktap";
+ private static final String SERVLET_URL = "/" + SERVLET_URL_WITHOUT_ROOT;
+ private static final long serialVersionUID = -23L;
+
+ private final Logger logger = LoggerFactory.getLogger(BindingServlet.class);
+ private final Object registerLock = new Object();
+
+ private volatile boolean registered;
+
+ @Nullable
+ HttpService httpService;
+ private @Nullable TranslationProvider translationProvider;
+ private @Nullable LocaleProvider localeProvider;
+ private @Nullable Bundle bundle;
+
+ public void setTranslationProviderInfo(TranslationProvider translationProvider, LocaleProvider localeProvider,
+ Bundle bundle) {
+ this.bundle = bundle;
+ this.localeProvider = localeProvider;
+ this.translationProvider = translationProvider;
+ }
+
+ public static final BindingServlet getInstance() {
+ return INSTANCE;
+ }
+
+ public String getLocalizedText(String key, @Nullable Object @Nullable... arguments) {
+ TranslationProvider translationProv = translationProvider;
+ LocaleProvider localeProv = localeProvider;
+ if (translationProv == null || localeProv == null) {
+ return key;
+ }
+ String result = translationProv.getText(bundle, key, key, localeProv.getLocale(), arguments);
+ return Objects.nonNull(result) ? result : key;
+ }
+
+ public void setHttpService(final HttpService httpService) {
+ this.httpService = httpService;
+ }
+
+ public static String getServletAddress(final String hostname, final String localizedWarning) {
+ final String httpPortStr = System.getProperty("org.osgi.service.http.port");
+ final Logger logger = LoggerFactory.getLogger(BindingServlet.class);
+ if (httpPortStr == null || httpPortStr.isEmpty()) {
+ logger.warn("{}", localizedWarning);
+ return EMPTY_STRING;
+ }
+ return "http://" + hostname + ":" + httpPortStr + SERVLET_URL;
+ }
+
+ public void registerServlet() {
+ final HttpService srv = httpService;
+ synchronized (registerLock) {
+ if (!registered && srv != null) {
+ try {
+ srv.registerServlet(SERVLET_URL, this, null, srv.createDefaultHttpContext());
+ registered = true;
+ logger.trace("Registered servlet " + SERVLET_URL);
+ } catch (NamespaceException | ServletException e) {
+ logger.warn("{}",
+ getLocalizedText("exception.fail-servlet-registration", SERVLET_URL, Utils.getMessage(e)));
+ }
+ }
+ }
+ }
+
+ public void unregisterServlet() {
+ final HttpService srv = httpService;
+ synchronized (registerLock) {
+ if (registered && srv != null) {
+ srv.unregister(SERVLET_URL);
+ registered = false;
+ logger.trace("Unregistered servlet");
+ }
+ }
+ }
+
+ @Override
+ protected void doPost(@Nullable HttpServletRequest req, @Nullable HttpServletResponse resp)
+ throws ServletException, IOException {
+ if (req == null) {
+ return;
+ }
+
+ int bufferSize = 1000; // The payload string is technically limited to 768 characters - this should be enough
+ // a single read to fully fit
+ char[] buffer = new char[bufferSize];
+ StringBuilder out = new StringBuilder();
+ try (Reader in = new InputStreamReader(req.getInputStream(), StandardCharsets.UTF_8)) {
+ for (int numRead; (numRead = in.read(buffer, 0, buffer.length)) > 0;) {
+ out.append(buffer, 0, numRead);
+ }
+ }
+
+ String payload = out.toString();
+ final TLGatewayFrame tlFrame = LinkTapBindingConstants.GSON.fromJson(payload, TLGatewayFrame.class);
+ String result = "";
+ if (tlFrame != null) {
+ TransactionProcessor tp = TransactionProcessor.getInstance();
+ result = tp.processGwRequest(req.getRemoteAddr(), tlFrame.command, payload);
+ }
+ if (resp == null) {
+ return;
+ }
+
+ resp.setContentType(MediaType.APPLICATION_JSON);
+ resp.setStatus(HttpStatus.OK_200);
+ resp.getWriter().append(result);
+ resp.getWriter().close();
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.servers;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jetty.client.HttpClient;
+
+/**
+ * Implementations of this interface, allow access to a HttpClient which can be used
+ * for communication requests to LinkTap Gateways.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public interface IHttpClientProvider {
+
+ /**
+ * This returns a HttpClient reference which can be used for communication requests.
+ *
+ * @return instance of HttpClient to use for requests
+ */
+ HttpClient getHttpClient();
+}
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<addon:addon id="linktap" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
+
+ <type>binding</type>
+ <name>LinkTap Binding</name>
+ <description>This is the binding for LinkTap.</description>
+ <connection>local</connection>
+
+ <discovery-methods>
+ <discovery-method>
+ <service-type>mdns</service-type>
+ <discovery-parameters>
+ <discovery-parameter>
+ <name>mdnsServiceType</name>
+ <value>_http._tcp.local.</value>
+ </discovery-parameter>
+ </discovery-parameters>
+ <match-properties>
+ <match-property>
+ <name>name</name>
+ <regex>^(LinkTapGw_)</regex>
+ </match-property>
+ </match-properties>
+ </discovery-method>
+ </discovery-methods>
+
+</addon:addon>
--- /dev/null
+# add-on
+
+addon.linktap.name = LinkTap Binding
+addon.linktap.description = This is the binding for LinkTap.
+
+# thing types
+
+thing-type.linktap.device.label = LinkTap Binding Thing
+thing-type.linktap.device.description = LinkTap Binding Device
+thing-type.linktap.gateway.label = LinkTap Gateway
+thing-type.linktap.gateway.description = This represents a LinkTap gateway
+
+# thing types config
+
+thing-type.config.linktap.device.enableAlerts.label = Auto Enable Alerts
+thing-type.config.linktap.device.enableAlerts.description = If enabled, during device initialisation all alerts are enabled
+thing-type.config.linktap.device.id.label = Device Id
+thing-type.config.linktap.device.id.description = The Device Id for the device under the gateway
+thing-type.config.linktap.device.name.label = Device Name
+thing-type.config.linktap.device.name.description = The name allocated to the device by the app. (Must be unique if used)
+thing-type.config.linktap.gateway.enableJSONComms.label = Enable non HTML responses
+thing-type.config.linktap.gateway.enableJSONComms.description = Enable only if openHAB is directly using the Gateway, to allow more efficient communications
+thing-type.config.linktap.gateway.enableMDNS.label = Enable mDNS Responder
+thing-type.config.linktap.gateway.enableMDNS.description = On connection whether the mDNS responder should be enabled on the gateway device
+thing-type.config.linktap.gateway.enforceProtocolLimits.label = Enforce protocol limits
+thing-type.config.linktap.gateway.enforceProtocolLimits.description = If parameters outside the limits acceptable to the device's are sent they will be blocked and logged
+thing-type.config.linktap.gateway.host.label = Hostname / IP
+thing-type.config.linktap.gateway.host.description = The hostname / IP address of the gateway device
+thing-type.config.linktap.gateway.password.label = Device Password
+thing-type.config.linktap.gateway.password.description = The password if set for the gateway device
+thing-type.config.linktap.gateway.username.label = Device Username
+thing-type.config.linktap.gateway.username.description = The username if set for the gateway device
+
+# channel types
+
+channel-type.linktap.battery-level-type.label = Battery Level
+channel-type.linktap.battery-level-type.description = Battery Remaining Level
+channel-type.linktap.child-lock-type.label = Child Lock Mode
+channel-type.linktap.child-lock-type.description = The child lock mode
+channel-type.linktap.child-lock-type.state.option.0 = Unlocked
+channel-type.linktap.child-lock-type.state.option.1 = Partially locked
+channel-type.linktap.child-lock-type.state.option.2 = Completely locked
+channel-type.linktap.fail-status-type.label = Shutdown Value Failed
+channel-type.linktap.fail-status-type.description = The device has failed to close the valve
+channel-type.linktap.failsafe-duration-type.label = Watering Cycle Failsafe
+channel-type.linktap.failsafe-duration-type.description = Failsafe duration of the current watering cycle
+channel-type.linktap.fall-status-type.label = Fallen Status
+channel-type.linktap.fall-status-type.description = The device has fallen
+channel-type.linktap.final-segment-type.label = Final ECO Segment
+channel-type.linktap.final-segment-type.description = In ECO mode this is true when the final ON watering on segment is running
+channel-type.linktap.flm-linked-type.label = FLM Linked
+channel-type.linktap.flm-linked-type.description = The device has a included flow meter
+channel-type.linktap.flow-rate-type.label = Flow Rate
+channel-type.linktap.flow-rate-type.description = Current water flow rate
+channel-type.linktap.instant-duration-type.label = Instant Duration Limit
+channel-type.linktap.instant-duration-type.description = Max duration allowed for the immediate watering
+channel-type.linktap.instant-limit-type.label = Instant Volume Limit
+channel-type.linktap.instant-limit-type.description = Max Volume limit for immediate watering
+channel-type.linktap.is-clog-type.label = Low Flow Detected
+channel-type.linktap.is-clog-type.description = Unusually low flow rate detected alert
+channel-type.linktap.is-leak-type.label = High Flow Detected
+channel-type.linktap.is-leak-type.description = Unusually high flow rate detected alert
+channel-type.linktap.man-mode-type.label = Manual Watering
+channel-type.linktap.man-mode-type.description = Manual watering mode status
+channel-type.linktap.mode-type.label = Watering Mode
+channel-type.linktap.mode-type.description = The watering mode
+channel-type.linktap.mode-type.state.option.0 = Off
+channel-type.linktap.mode-type.state.option.1 = Instant
+channel-type.linktap.mode-type.state.option.2 = Calendar
+channel-type.linktap.mode-type.state.option.3 = Day
+channel-type.linktap.mode-type.state.option.4 = Odd-even
+channel-type.linktap.mode-type.state.option.5 = Interval
+channel-type.linktap.mode-type.state.option.6 = Month
+channel-type.linktap.pause-enable-type.label = Pause plan schedule
+channel-type.linktap.pause-enable-type.description = When ON will pause the current watering plan for an hour every 55 minutes
+channel-type.linktap.pause-until-type.label = Plan Paused Until
+channel-type.linktap.pause-until-type.description = Displays when the last pause issued will expiry, resuming the current watering plan
+channel-type.linktap.plan-id-type.label = Watering Plan Id
+channel-type.linktap.plan-id-type.description = Displays the current watering plan id
+channel-type.linktap.remaining-duration-type.label = Watering Cycle Remaining
+channel-type.linktap.remaining-duration-type.description = Remaining duration of the current watering cycle
+channel-type.linktap.rf-linked-type.label = RF Linked
+channel-type.linktap.rf-linked-type.description = Is the device RF linked
+channel-type.linktap.signal-level-type.label = Signal Level
+channel-type.linktap.signal-level-type.description = Reception Signal Strength
+channel-type.linktap.total-duration-type.label = Watering Cycle Duration
+channel-type.linktap.total-duration-type.description = Total duration of current watering cycle
+channel-type.linktap.volume-limit-type.label = Current Watering Limit
+channel-type.linktap.volume-limit-type.description = Volume limit for the current watering cycle
+channel-type.linktap.volume-type.label = Current Watering Volume
+channel-type.linktap.volume-type.description = Accumulated volume of current watering cycle
+channel-type.linktap.water-cut-type.label = Water Cutoff
+channel-type.linktap.water-cut-type.description = Water cut-off alert
+channel-type.linktap.watering-type.label = Watering
+channel-type.linktap.watering-type.description = Active watering status
+
+# thing types
+
+thing-type.linktap.bridge.label = LinkTap Bridge
+thing-type.linktap.bridge.description = The LinkTap bridge represents a LinkTap gateway device
+
+# thing types config
+
+thing-type.config.linktap.bridge.enableMDNS.label = Enable mDNS Responder
+thing-type.config.linktap.bridge.enableMDNS.description = On connection whether the mDNS responder should be enabled on the gateway device
+thing-type.config.linktap.bridge.host.label = Hostname / IP
+thing-type.config.linktap.bridge.host.description = The hostname / IP address of the gateway device
+thing-type.config.linktap.bridge.password.label = Device Password
+thing-type.config.linktap.bridge.password.description = The password if set for the gateway device
+thing-type.config.linktap.bridge.username.label = Device Username
+thing-type.config.linktap.bridge.username.description = The username if set for the gateway device
+
+# channel types
+
+channel-type.linktap.future-rain-type.label = Watering Skipped Future
+channel-type.linktap.future-rain-type.description = Future rainfall calculated when watering was skipped
+channel-type.linktap.prev-rain-type.label = Watering Skipped Previous
+channel-type.linktap.prev-rain-type.description = Previous rainfall calculated when watering was skipped
+channel-type.linktap.rain-timestamp-type.label = Watering Skipped Timestamp
+channel-type.linktap.rain-timestamp-type.description = Time when watering was skipped
+
+# channel types
+
+channel-type.linktap.instant-volume-limit-type.label = OH Instance On Watering Limit
+channel-type.linktap.instant-volume-limit-type.description = Max Volume limit for immediate watering
+
+# channel types
+
+channel-type.linktap.battery-level.label = Battery Level
+channel-type.linktap.battery-level.description = Battery Remaining Level
+channel-type.linktap.child-lock-mode.label = Child-lock mode
+channel-type.linktap.child-lock-mode.description = The child lock mode
+channel-type.linktap.child-lock-mode.state.option.0 = Unlocked
+channel-type.linktap.child-lock-mode.state.option.1 = Partially locked
+channel-type.linktap.child-lock-mode.state.option.2 = Completely locked
+channel-type.linktap.fail-status.label = Shutdown value failed
+channel-type.linktap.fail-status.description = The device has failed to close the valve
+channel-type.linktap.failsafe-duration.label = Watering Cycle Failsafe
+channel-type.linktap.failsafe-duration.description = Failsafe duration of the current watering cycle
+channel-type.linktap.fall-status.label = Fallen status
+channel-type.linktap.fall-status.description = The device has fallen
+channel-type.linktap.final-segment.label = Final ECO Segment
+channel-type.linktap.final-segment.description = In ECO mode this is true when the final ON watering on segment is running
+channel-type.linktap.flm-linked.label = FLM Linked
+channel-type.linktap.flm-linked.description = The device has a included flow meter
+channel-type.linktap.flow-rate.label = Flow Rate
+channel-type.linktap.flow-rate.description = Current water flow rate
+channel-type.linktap.instant-duration.label = OH Instance On Duration
+channel-type.linktap.instant-duration.description = Max duration allowed for the immediate watering
+channel-type.linktap.instant-volume-limit.label = OH Instance On Watering Limit
+channel-type.linktap.instant-volume-limit.description = Max Volume limit for immediate watering
+channel-type.linktap.is-clog.label = Low Flow Detected
+channel-type.linktap.is-clog.description = Unusually low flow rate detected alert
+channel-type.linktap.is-leak.label = High Flow Detected
+channel-type.linktap.is-leak.description = Unusually high flow rate detected alert
+channel-type.linktap.man-mode.label = Manual watering
+channel-type.linktap.man-mode.description = Manual watering mode status
+channel-type.linktap.mode.label = Watering mode
+channel-type.linktap.mode.description = The watering mode
+channel-type.linktap.mode.state.option.0 = Off
+channel-type.linktap.mode.state.option.1 = Instant
+channel-type.linktap.mode.state.option.2 = Calendar
+channel-type.linktap.mode.state.option.3 = Day
+channel-type.linktap.mode.state.option.4 = Odd-even
+channel-type.linktap.mode.state.option.5 = Interval
+channel-type.linktap.mode.state.option.6 = Month
+channel-type.linktap.remaining-duration.label = Watering Cycle Remaining
+channel-type.linktap.remaining-duration.description = Remaining duration of the current watering cycle
+channel-type.linktap.rf-inked.label = RF Linked
+channel-type.linktap.rf-inked.description = Is the device RF linked
+channel-type.linktap.signal-level.label = Signal Level
+channel-type.linktap.signal-level.description = Reception Signal Strength
+channel-type.linktap.skip-future-rain.label = Watering Skipped Future Rain
+channel-type.linktap.skip-future-rain.description = Future rainfall calculated when watering was skipped
+channel-type.linktap.skip-prev-rain.label = Watering Skipped Previous Rain
+channel-type.linktap.skip-prev-rain.description = Previous rainfall calculated when watering was skipped
+channel-type.linktap.skip-timestamp.label = Watering Skipped Timestamp
+channel-type.linktap.skip-timestamp.description = Time when watering was skipped
+channel-type.linktap.total-duration.label = Watering Cycle Duration
+channel-type.linktap.total-duration.description = Total duration of current watering cycle
+channel-type.linktap.volume-limit.label = Current Watering Limit
+channel-type.linktap.volume-limit.description = Volume limit for the current watering cycle
+channel-type.linktap.volume.label = Current Watering Volume
+channel-type.linktap.volume.description = Accumulated volume of current watering cycle
+channel-type.linktap.water-cut-off.label = Water Cutoff
+channel-type.linktap.water-cut-off.description = Water cut-off alert
+channel-type.linktap.watering.label = Watering
+channel-type.linktap.watering.description = Active watering status
+
+# channel types
+
+channel-type.linktap.deviceBatteryLevel.label = Battery Level
+channel-type.linktap.deviceBatteryLevel.description = Battery Remaining Level
+channel-type.linktap.deviceChildLockMode.label = Child-lock mode
+channel-type.linktap.deviceChildLockMode.description = The child lock mode
+channel-type.linktap.deviceChildLockMode.state.option.0 = Unlocked
+channel-type.linktap.deviceChildLockMode.state.option.1 = Partially locked
+channel-type.linktap.deviceChildLockMode.state.option.2 = Completely locked
+channel-type.linktap.deviceFailStatus.label = Shutdown value failed
+channel-type.linktap.deviceFailStatus.description = The device has failed to close the valve
+channel-type.linktap.deviceFailsafeDuration.label = Watering Cycle Failsafe
+channel-type.linktap.deviceFailsafeDuration.description = Failsafe duration of the current watering cycle
+channel-type.linktap.deviceFallStatus.label = Fallen status
+channel-type.linktap.deviceFallStatus.description = The device has fallen
+channel-type.linktap.deviceFinalSegment.label = Final ECO Segment
+channel-type.linktap.deviceFinalSegment.description = In ECO mode this is true when the final ON watering on segment is running
+channel-type.linktap.deviceFlmLinked.label = FLM Linked
+channel-type.linktap.deviceFlmLinked.description = The device has a included flow meter
+channel-type.linktap.deviceFlowRate.label = Flow Rate
+channel-type.linktap.deviceFlowRate.description = Current water flow rate
+channel-type.linktap.deviceIsClog.label = Low Flow Detected
+channel-type.linktap.deviceIsClog.description = Unusually low flow rate detected alert
+channel-type.linktap.deviceIsLeak.label = High Flow Detected
+channel-type.linktap.deviceIsLeak.description = Unusually high flow rate detected alert
+channel-type.linktap.deviceManMode.label = Manual watering
+channel-type.linktap.deviceManMode.description = Manual watering mode status
+channel-type.linktap.deviceMode.label = Watering mode
+channel-type.linktap.deviceMode.description = The watering mode
+channel-type.linktap.deviceMode.state.option.0 = Off
+channel-type.linktap.deviceMode.state.option.1 = Instant
+channel-type.linktap.deviceMode.state.option.2 = Calendar
+channel-type.linktap.deviceMode.state.option.3 = Day
+channel-type.linktap.deviceMode.state.option.4 = Odd-even
+channel-type.linktap.deviceMode.state.option.5 = Interval
+channel-type.linktap.deviceMode.state.option.6 = Month
+channel-type.linktap.deviceRemainingDuration.label = Watering Cycle Remaining
+channel-type.linktap.deviceRemainingDuration.description = Remaining duration of the current watering cycle
+channel-type.linktap.deviceRfLinked.label = RF Linked
+channel-type.linktap.deviceRfLinked.description = Is the device RF linked
+channel-type.linktap.deviceSignalLevel.label = Signal Level
+channel-type.linktap.deviceSignalLevel.description = Reception Signal Strength
+channel-type.linktap.deviceTotalDuration.label = Watering Cycle Duration
+channel-type.linktap.deviceTotalDuration.description = Total duration of current watering cycle
+channel-type.linktap.deviceVolume.label = Current Watering Volume
+channel-type.linktap.deviceVolume.description = Accumulated volume of current watering cycle
+channel-type.linktap.deviceVolumeLimit.label = Current Watering Limit
+channel-type.linktap.deviceVolumeLimit.description = Volume limit for the current watering cycle
+channel-type.linktap.deviceWaterCutoff.label = Water Cutoff
+channel-type.linktap.deviceWaterCutoff.description = Water cut-off alert
+channel-type.linktap.deviceWatering.label = Watering
+channel-type.linktap.deviceWatering.description = Active watering status
+channel-type.linktap.ohInstantDuration.label = OH Instance On Duration
+channel-type.linktap.ohInstantDuration.description = Max duration allowed for the immediate watering
+channel-type.linktap.ohInstantVolumeLimit.label = OH Instance On Watering Limit
+channel-type.linktap.ohInstantVolumeLimit.description = Max Volume limit for immediate watering
+channel-type.linktap.waterSkipDateTime.label = Watering Skipped Timestamp
+channel-type.linktap.waterSkipDateTime.description = Time when watering was skipped
+channel-type.linktap.waterSkipFutureRain.label = Watering Skipped Future Rain
+channel-type.linktap.waterSkipFutureRain.description = Future rainfall calculated when watering was skipped
+channel-type.linktap.waterSkipPrevRain.label = Watering Skipped Previous Rain
+channel-type.linktap.waterSkipPrevRain.description = Previous rainfall calculated when watering was skipped
+
+# errors
+
+bridge.error.host-not-found = Hostname / IP cannot be found
+bridge.error.check-credentials = Check credentials provided
+bridge.error.cannot-connect = Cannot connect to LinkTap Gateway
+bridge.error.unknown-host = Unknown host
+bridge.error.target-is-not-gateway = Target Host is not a LinkTap Gateway
+polling-device.error.bridge-unset = Bridge is not selected / set
+polling-device.error.device-unknown-in-bridge = Device not found in bridges known devices
+polling-device.error.unknown-device-id = Bridge does not recognise device id
+polling-device.error.unknown-device = Check device setup - device is unknown
+protocol.ret.success = Success
+protocol.ret.format-error = Message format error
+protocol.ret.cmd-unsupported = CMD message not supported
+protocol.ret.gw-id-unmatched = Gateway ID not matched
+protocol.ret.end-device-id-error = End device ID error
+protocol.ret.end-device-id-not-found = End device ID not found
+protocol.ret.gw-internal-error = Gateway internal error
+protocol.ret.conflict-watering-plan = Conflict with watering plan
+protocol.ret.gw-busy = Gateway busy
+protocol.ret.bad-parameter-in-msg = Bad parameter in message
+protocol.ret.invalid = Missing ret response
+warning.failed-local-address-detection = Failed to determine local openHab address due to connection failure with exception {}
+warning.no-http-server-port = HTTP Server port is not running, cannot use API callbacks
+warning.fw-update-local-config = {0} -> Local configuration support requires newer firmware it should be >= {1}
+warning.user-data-payload-failure = Device {0} payload validation failed - will not send due to bad data -> {1}
+warning.parameter-not-accepted = Parameter not accepted by device {0} for command {1}
+warning.response-from-wrong-gw-id = {0} = Response from incorrect Gateway "{1}" != "{2}"
+warning.incorrect-cmd-resp = {0} = Received incorrect CMD response {1} != {2}
+warning.non-gw = Communicating with non TapLink Gateway detected
+warning.not-taplink-gw = {0} = {1} is not a Link Tap Gateway!
+warning.comms-issue-auto-retry = {0} = Possible communications issue (auto retry): {1}
+warning.device-no-accept = Device {0} did not accept command {1}
+warning.error-with-gw-id = Error with gateway ID
+warning.host-gw-unknown-for-cmd = Request when host "{0}" or gateway "{1}" id is unknown for command {2}
+warning.discovery-charset-missing = Missing character set to decode MDNS Text
+warning.unexpected-response-frame = Unexpected response frame {0} -> {1}
+warning.unexpected-cmd-result = Unexpected command result
+bug-report.failed-alert-enable = Raise Bug Report: {0} - Failed to enable all alerts - invalid parameter exception
+bug-report.poll-failure = Raise Bug Report: {0} - Poll failure - invalid parameter exception
+bug-report.pause-plan-failure = Raise Bug Report: {0} - Pause plan failure - invalid parameter exception
+bug-report.unexpected-payload-failure = Potential Bug: Device {0} payload validation failed - will not send -> {1}
+bug-report.gw-unsupported-command = Raise Bug Report: Command {0} not supported by gateway
+exception.device-id-exception = Device ID Exception
+exception.gw-id-exception = Gateway ID Exception
+exception-cmd-not-supported-exception = Command Not Supported Exception
+exception.invalid-parameter-exception = Invalid Parameter Exception
+exception.could-not-connect = Could not connect
+exception.could-not-resolve = Could not resolve IP address
+exception.communications-lost = Communications Lost
+exception.local-addr-lookup-failure = Local address lookup failure
+exception.exec-exception = ExecutionException -> {0}
+exception.not-gw.missing-headers = Missing header markers
+exception.not-gw.missing-title = Not a LinkTap API response
+exception.not-gw.missing-server-title = Not a LinkTap response
+exception.not-gw.unexpected-status-code = Unexpected status code response
+exception.not-gw.unexpected-protocol = Unexpected protocol
+exception.not-tap-link-gw = Not a TapLink GW
+exception.fail-servlet-registration = Register servlet failed for {0}
+exception.unexpected-failure = Unexpected failure -> {0}
+exception.unexpected-exception = Unexpected exception
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="linktap"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+ <channel-type id="mode-type">
+ <item-type>String</item-type>
+ <label>Watering Mode</label>
+ <description>The watering mode</description>
+ <category>Time</category>
+ <state readOnly="false">
+ <options>
+ <option value="0">Off</option>
+ <option value="1">Instant</option>
+ <option value="2">Calendar</option>
+ <option value="3">Day</option>
+ <option value="4">Odd-even</option>
+ <option value="5">Interval</option>
+ <option value="6">Month</option>
+ </options>
+ </state>
+ </channel-type>
+
+ <channel-type id="man-mode-type">
+ <item-type>Switch</item-type>
+ <label>Manual Watering</label>
+ <description>Manual watering mode status</description>
+ <category>Water</category>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="watering-type">
+ <item-type>Switch</item-type>
+ <label>Watering</label>
+ <description>Active watering status</description>
+ <category>Water</category>
+ <state readOnly="false"/>
+ </channel-type>
+
+ <channel-type id="rf-linked-type">
+ <item-type>Switch</item-type>
+ <label>RF Linked</label>
+ <description>Is the device RF linked</description>
+ <category>Switch</category>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="flm-linked-type">
+ <item-type>Switch</item-type>
+ <label>FLM Linked</label>
+ <description>The device has a included flow meter</description>
+ <category>Switch</category>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="is-leak-type">
+ <item-type>Switch</item-type>
+ <label>High Flow Detected</label>
+ <description>Unusually high flow rate detected alert</description>
+ <category>Alarm</category>
+ <state readOnly="false"/>
+ </channel-type>
+
+ <channel-type id="is-clog-type">
+ <item-type>Switch</item-type>
+ <label>Low Flow Detected</label>
+ <description>Unusually low flow rate detected alert</description>
+ <category>Alarm</category>
+ <state readOnly="false"/>
+ </channel-type>
+
+ <channel-type id="fall-status-type">
+ <item-type>Switch</item-type>
+ <label>Fallen Status</label>
+ <description>The device has fallen</description>
+ <category>Alarm</category>
+ <state readOnly="false"/>
+ </channel-type>
+
+ <channel-type id="fail-status-type">
+ <item-type>Switch</item-type>
+ <label>Shutdown Value Failed</label>
+ <description>The device has failed to close the valve</description>
+ <category>Alarm</category>
+ <state readOnly="false"/>
+ </channel-type>
+
+ <channel-type id="final-segment-type">
+ <item-type>Switch</item-type>
+ <label>Final ECO Segment</label>
+ <description>In ECO mode this is true when the final ON watering on segment is running</description>
+ <category>Switch</category>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="signal-level-type">
+ <item-type>Number:Dimensionless</item-type>
+ <label>Signal Level</label>
+ <description>Reception Signal Strength</description>
+ <category>QualityOfService</category>
+ <state readOnly="true" pattern="%.0f %%"/>
+ </channel-type>
+
+ <channel-type id="battery-level-type">
+ <item-type>Number:Dimensionless</item-type>
+ <label>Battery Level</label>
+ <description>Battery Remaining Level</description>
+ <category>BatteryLevel</category>
+ <state readOnly="true" pattern="%.0f %%"/>
+ </channel-type>
+
+ <channel-type id="water-cut-type">
+ <item-type>Switch</item-type>
+ <label>Water Cutoff</label>
+ <description>Water cut-off alert</description>
+ <category>Alarm</category>
+ <state readOnly="false"/>
+ </channel-type>
+
+ <channel-type id="flow-rate-type">
+ <item-type unitHint="l/min">Number:VolumetricFlowRate</item-type>
+ <label>Flow Rate</label>
+ <description>Current water flow rate</description>
+ <category>Flow</category>
+ <state readOnly="true" pattern="%.2f %unit%"/>
+ </channel-type>
+
+ <channel-type id="volume-type">
+ <item-type unitHint="l">Number:Volume</item-type>
+ <label>Current Watering Volume</label>
+ <description>Accumulated volume of current watering cycle</description>
+ <category>Water</category>
+ <state readOnly="true" pattern="%.2f %unit%"/>
+ </channel-type>
+
+ <channel-type id="volume-limit-type">
+ <item-type unitHint="l">Number:Volume</item-type>
+ <label>Current Watering Limit</label>
+ <description>Volume limit for the current watering cycle</description>
+ <category>Water</category>
+ <state readOnly="true" pattern="%.2f %unit%"/>
+ </channel-type>
+
+ <channel-type id="total-duration-type">
+ <item-type unitHint="s">Number:Time</item-type>
+ <label>Watering Cycle Duration</label>
+ <description>Total duration of current watering cycle</description>
+ <category>Time</category>
+ <state readOnly="true" pattern="%.2f %unit%"/>
+ </channel-type>
+
+ <channel-type id="remaining-duration-type">
+ <item-type unitHint="s">Number:Time</item-type>
+ <label>Watering Cycle Remaining</label>
+ <description>Remaining duration of the current watering cycle</description>
+ <category>Time</category>
+ <state readOnly="true" pattern="%.2f %unit%"/>
+ </channel-type>
+
+ <channel-type id="failsafe-duration-type">
+ <item-type unitHint="s">Number:Time</item-type>
+ <label>Watering Cycle Failsafe</label>
+ <description>Failsafe duration of the current watering cycle</description>
+ <category>Time</category>
+ <state readOnly="true" pattern="%.2f %unit%"/>
+ </channel-type>
+
+ <channel-type id="child-lock-type">
+ <item-type>String</item-type>
+ <label>Child Lock Mode</label>
+ <description>The child lock mode</description>
+ <category>Lock</category>
+ <state readOnly="false">
+ <options>
+ <option value="0">Unlocked</option>
+ <option value="1">Partially locked</option>
+ <option value="2">Completely locked</option>
+ </options>
+ </state>
+ </channel-type>
+
+ <channel-type id="instant-duration-type">
+ <item-type unitHint="s">Number:Time</item-type>
+ <label>Instant Duration Limit</label>
+ <description>Max duration allowed for the immediate watering</description>
+ <category>Time</category>
+ <state readOnly="false" pattern="%.2f %unit%"/>
+ </channel-type>
+
+ <channel-type id="instant-limit-type">
+ <item-type unitHint="l">Number:Volume</item-type>
+ <label>Instant Volume Limit</label>
+ <description>Max Volume limit for immediate watering</description>
+ <category>Water</category>
+ <state readOnly="false" pattern="%.2f %unit%"/>
+ </channel-type>
+
+ <channel-type id="pause-enable-type">
+ <item-type>Switch</item-type>
+ <label>Pause plan schedule</label>
+ <description>When ON will pause the current watering plan for an hour every 55 minutes</description>
+ <category>Time</category>
+ <state readOnly="false"/>
+ </channel-type>
+
+ <channel-type id="pause-until-type">
+ <item-type>DateTime</item-type>
+ <label>Plan Paused Until</label>
+ <description>Displays when the last pause issued will expiry, resuming the current watering plan</description>
+ <category>Calendar</category>
+ <state readOnly="true"/>
+ </channel-type>
+
+ <channel-type id="plan-id-type">
+ <item-type>String</item-type>
+ <label>Watering Plan Id</label>
+ <description>Displays the current watering plan id</description>
+ <category>Calendar</category>
+ <state readOnly="true"/>
+ </channel-type>
+
+</thing:thing-descriptions>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="linktap"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+ <bridge-type id="gateway">
+ <label>LinkTap Gateway</label>
+ <description>This represents a LinkTap gateway</description>
+
+ <properties>
+ <property name="gatewayId"/>
+ <property name="hardwareModel"/>
+ <property name="version"/>
+ <property name="macAddress"/>
+ <property name="httpApiEnabled"/>
+ <property name="httpApiCallback"/>
+ <property name="volumeUnit"/>
+ <property name="utcOffset"/>
+ </properties>
+
+ <config-description>
+ <parameter name="host" type="text" required="true">
+ <context>network-address</context>
+ <label>Hostname / IP</label>
+ <description>The hostname / IP address of the gateway device</description>
+ </parameter>
+ <parameter name="username" type="text" required="false">
+ <label>Device Username</label>
+ <description>The username if set for the gateway device</description>
+ </parameter>
+ <parameter name="password" type="text" required="false">
+ <context>password</context>
+ <label>Device Password</label>
+ <description>The password if set for the gateway device</description>
+ </parameter>
+ <parameter name="enableMDNS" type="boolean">
+ <label>Enable mDNS Responder</label>
+ <description>On connection whether the mDNS responder should be enabled on the gateway device</description>
+ <default>true</default>
+ <advanced>true</advanced>
+ </parameter>
+ <parameter name="enableJSONComms" type="boolean">
+ <label>Enable non HTML responses</label>
+ <description>Enable only if openHAB is directly using the Gateway, to allow more efficient communications</description>
+ <default>false</default>
+ <advanced>true</advanced>
+ </parameter>
+ <parameter name="enforceProtocolLimits" type="boolean">
+ <label>Enforce protocol limits</label>
+ <description>If parameters outside the limits acceptable to the device's are sent they will be blocked and logged</description>
+ <default>true</default>
+ <advanced>true</advanced>
+ </parameter>
+ </config-description>
+ </bridge-type>
+
+ <thing-type id="device">
+
+ <supported-bridge-type-refs>
+ <bridge-type-ref id="gateway"/>
+ </supported-bridge-type-refs>
+
+ <label>LinkTap Binding Thing</label>
+ <description>LinkTap Binding Device</description>
+
+ <channels>
+ <channel id="mode" typeId="mode-type"/>
+ <channel id="manual-watering" typeId="man-mode-type"/>
+ <channel id="watering" typeId="watering-type"/>
+ <channel id="rf-linked" typeId="rf-linked-type"/>
+ <channel id="flm-linked" typeId="flm-linked-type"/>
+ <channel id="water-cut" typeId="water-cut-type"/>
+ <channel id="fall-status" typeId="fall-status-type"/>
+ <channel id="shutdown-failure" typeId="fail-status-type"/>
+ <channel id="high-flow" typeId="is-leak-type"/>
+ <channel id="low-flow" typeId="is-clog-type"/>
+ <channel id="eco-final" typeId="final-segment-type"/>
+ <channel id="signal" typeId="signal-level-type"/>
+ <channel id="battery" typeId="battery-level-type"/>
+ <channel id="child-lock" typeId="child-lock-type"/>
+ <channel id="flow-rate" typeId="flow-rate-type"/>
+ <channel id="volume" typeId="volume-type"/>
+ <channel id="duration" typeId="total-duration-type"/>
+ <channel id="remaining" typeId="remaining-duration-type"/>
+ <channel id="dur-limit" typeId="failsafe-duration-type"/>
+ <channel id="vol-limit" typeId="volume-limit-type"/>
+ <channel id="oh-dur-limit" typeId="instant-duration-type"/>
+ <channel id="oh-vol-limit" typeId="instant-limit-type"/>
+ <channel id="plan-pause-enable" typeId="pause-enable-type"/>
+ <channel id="plan-resume-time" typeId="pause-until-type"/>
+ <channel id="watering-plan-id" typeId="plan-id-type"/>
+ </channels>
+
+ <properties>
+ <property name="deviceId">Device Id</property>
+ <property name="deviceName">Device Name</property>
+ </properties>
+ <representation-property>deviceId</representation-property>
+
+ <config-description>
+ <parameter name="id" type="text" required="false">
+ <label>Device Id</label>
+ <description>The Device Id for the device under the gateway</description>
+ </parameter>
+ <parameter name="name" type="text" required="false">
+ <label>Device Name</label>
+ <description>The name allocated to the device by the app. (Must be unique if used)</description>
+ </parameter>
+ <parameter name="enableAlerts" type="boolean" required="false">
+ <label>Auto Enable Alerts</label>
+ <description>If enabled, during device initialisation all alerts are enabled</description>
+ <default>true</default>
+ </parameter>
+ </config-description>
+
+ </thing-type>
+
+</thing:thing-descriptions>
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.linktap.internal.LinkTapBindingConstants;
+
+import java.util.Arrays;
+import java.util.Properties;
+import java.util.Vector;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_HANDSHAKE;
+
+/**
+ * Command 0: Handshake
+ * Flow 1 --> GW->Broker->App: First message after connection with system device mappings
+ */
+@NonNullByDefault
+public class Command0Test {
+
+ /**
+ * Command 0:
+ * Flow 1 --> GW->Broker->App: First handshake message decoding test
+ */
+ @Test
+ public void HandshakeRequestDecoding() {
+ final HandshakeReq decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":0, \"gw_id\":\"CCCCDDDDEEEEFFFF\", \"ver\":\"G0404172103261024C\", \"end_dev\":[ \"1111222233334444\", \"7777888933336666\", \"2245222233334444\", \"3333999993333555\"\n]\n}",HandshakeReq.class);
+
+ assertEquals(CMD_HANDSHAKE,decoded.command);
+ assertEquals("CCCCDDDDEEEEFFFF",decoded.gatewayId );
+ assertEquals("G0404172103261024C",decoded.version);
+ assertEquals(4,decoded.endDevices.length);
+ assertTrue(Arrays.asList(decoded.endDevices).contains("1111222233334444"));
+ assertTrue(Arrays.asList(decoded.endDevices).contains("7777888933336666"));
+ assertTrue(Arrays.asList(decoded.endDevices).contains("2245222233334444"));
+ assertTrue(Arrays.asList(decoded.endDevices).contains("3333999993333555"));
+ }
+
+ /**
+ * Command 0:
+ * Flow 1 --> GW->Broker->App: First handshake message response encoding test
+ */
+ @Test
+ public void HandshakeResponseEncoding() {
+ HandshakeResp reply = new HandshakeResp();
+ reply.command = CMD_HANDSHAKE;
+ reply.gatewayId = "CCCCDDDDEEEEFFFF";
+ reply.date = "20210501";
+ reply.time = "123055";
+ reply.wday = 6;
+
+ String encoded = LinkTapBindingConstants.GSON.toJson(reply);
+
+ assertEquals("{\"date\":\"20210501\",\"time\":\"123055\",\"wday\":6,\"cmd\":0,\"gw_id\":\"CCCCDDDDEEEEFFFF\"}",
+ encoded);
+ }
+
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.linktap.internal.LinkTapBindingConstants;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_ALERT_DISMISS;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_ALERT_ENABLEMENT;
+
+/**
+ * Command 10: Enable or Disable alert type
+ * Flow 1 --> App->Broker->GW: Enable or Disable the given alert type
+ */
+@NonNullByDefault
+public class Command10Test {
+
+ /**
+ * Command 10: Enable or Disable alert type
+ * Flow 1 --> App->Broker->GW: Enable or Disable the given alert type
+ */
+ @Test
+ public void ChangeAlertStateGenerationTest() {
+ AlertStateReq req = new AlertStateReq();
+ req.command = CMD_ALERT_ENABLEMENT;
+ req.gatewayId = "CCCCDDDDEEEEFFFF";
+ req.deviceId = "1111222233334444";
+ req.alert = 0;
+ req.enable = true;
+
+ String encoded = LinkTapBindingConstants.GSON.toJson(req);
+
+ assertEquals("{\"enable\":true,\"alert\":0,\"dev_id\":\"1111222233334444\",\"cmd\":10,\"gw_id\":\"CCCCDDDDEEEEFFFF\"}",
+ encoded);
+ }
+
+ /**
+ * Command 11: Dismiss Alert
+ * Flow 1 --> App->Broker->GW: Dismiss the specified alert type reply
+ */
+ @Test
+ public void ChangeAlertStateResponseDecoding() {
+ final EndpointDeviceResponse decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":10, \"gw_id\":\"CCCCDDDDEEEEFFFF\", \"dev_id\":\"1111222233334444\", \"ret\":0\n}",EndpointDeviceResponse.class);
+
+ assertEquals(CMD_ALERT_ENABLEMENT,decoded.command);
+ assertEquals("CCCCDDDDEEEEFFFF",decoded.gatewayId );
+ assertEquals("1111222233334444",decoded.deviceId );
+ assertEquals(GatewayDeviceResponse.ResultStatus.RET_SUCCESS,decoded.getRes());
+ }
+
+
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.linktap.internal.LinkTapBindingConstants;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_ALERT_DISMISS;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_RAINFALL_DATA;
+
+/**
+ * Command 11: Dismiss Alert
+ * Flow 1 --> App->Broker->GW: Dismiss the specified alert type
+ */
+@NonNullByDefault
+public class Command11Test {
+
+ /**
+ * Command 11: Dismiss Alert
+ * Flow 1 --> App->Broker->GW: Dismiss the specified alert type
+ */
+ @Test
+ public void DismissAlertGenerationTest() {
+ DismissAlertReq req = new DismissAlertReq();
+ req.command = CMD_ALERT_DISMISS;
+ req.gatewayId = "CCCCDDDDEEEEFFFF";
+ req.deviceId = "1111222233334444";
+ req.alert = 0;
+
+ String encoded = LinkTapBindingConstants.GSON.toJson(req);
+
+ assertEquals("{\"alert\":0,\"dev_id\":\"1111222233334444\",\"cmd\":11,\"gw_id\":\"CCCCDDDDEEEEFFFF\"}",
+ encoded);
+ }
+
+ /**
+ * Command 11: Dismiss Alert
+ * Flow 1 --> App->Broker->GW: Dismiss the specified alert type reply
+ */
+ @Test
+ public void DismissAlertResponseDecoding() {
+ final EndpointDeviceResponse decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":11, \"gw_id\":\"CCCCDDDDEEEEFFFF\", \"dev_id\":\"1111222233334444\", \"ret\":0\n}",EndpointDeviceResponse.class);
+
+ assertEquals(CMD_ALERT_DISMISS,decoded.command);
+ assertEquals("CCCCDDDDEEEEFFFF",decoded.gatewayId );
+ assertEquals("1111222233334444",decoded.deviceId );
+ assertEquals(GatewayDeviceResponse.ResultStatus.RET_SUCCESS,decoded.getRes());
+ }
+
+
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.linktap.internal.LinkTapBindingConstants;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_ALERT_DISMISS;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_LOCKOUT_STATE;
+
+/**
+ * Command 12: Set lock state
+ * Flow 1 --> App->Broker->GW: Request lock status is changed - G15 and G25 models only
+ */
+@NonNullByDefault
+public class Command12Test {
+
+ /**
+ * Command 12: Set lock state
+ * Flow 1 --> App->Broker->GW: Request lock status is changed - G15 and G25 models only
+ */
+ @Test
+ public void SetLockStateGenerationTest() {
+ LockReq req = new LockReq();
+ req.command = CMD_LOCKOUT_STATE;
+ req.gatewayId = "CCCCDDDDEEEEFFFF";
+ req.deviceId = "1111222233334444";
+ req.lock = 0;
+
+ String encoded = LinkTapBindingConstants.GSON.toJson(req);
+
+ assertEquals("{\"lock\":0,\"dev_id\":\"1111222233334444\",\"cmd\":12,\"gw_id\":\"CCCCDDDDEEEEFFFF\"}",
+ encoded);
+ }
+
+ /**
+ * Command 12: Set lock state
+ * Flow 1 --> App->Broker->GW: Request lock status is changed response decoding
+ */
+ @Test
+ public void SetLockStateResponseDecoding() {
+ final EndpointDeviceResponse decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":12, \"gw_id\":\"CCCCDDDDEEEEFFFF\", \"dev_id\":\"1111222233334444\", \"ret\":0\n}",EndpointDeviceResponse.class);
+
+ assertEquals(CMD_LOCKOUT_STATE,decoded.command);
+ assertEquals("CCCCDDDDEEEEFFFF",decoded.gatewayId );
+ assertEquals("1111222233334444",decoded.deviceId );
+ assertEquals(GatewayDeviceResponse.ResultStatus.RET_SUCCESS,decoded.getRes());
+ }
+
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.linktap.internal.LinkTapBindingConstants;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.*;
+
+/**
+ * Command 13: Sync Gateway time
+ * Flow 1 --> GW->Broker->App: Request from Gateway for the current system time
+ */
+@NonNullByDefault
+public class Command13Test {
+
+ /**
+ * Command 13: Sync Gateway time
+ * Flow 1 --> GW->Broker->App: Response sent from the App to the Gateway with the time information
+ */
+ @Test
+ public void ResponseForAppTimeRequestGenerationTest() {
+ HandshakeResp req = new HandshakeResp();
+ req.gatewayId = "CCCCDDDDEEEEFFFF";
+ req.command = CMD_DATETIME_SYNC;
+ req.date = "20210501";
+ req.time = "123055";
+ req.wday = 6;
+
+ String encoded = LinkTapBindingConstants.GSON.toJson(req);
+
+ assertEquals("{\"date\":\"20210501\",\"time\":\"123055\",\"wday\":6,\"cmd\":13,\"gw_id\":\"CCCCDDDDEEEEFFFF\"}",
+ encoded);
+ }
+
+ /**
+ * Command 13: Sync Gateway time
+ * Flow 1 --> GW->Broker->App: Request from Gateway for the current system time
+ */
+ @Test
+ public void RequestForAppTimeDecoding() {
+ final TLGatewayFrame decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":13, \"gw_id\":\"CCCCDDDDEEEEFFFF\"\n}",EndpointDeviceResponse.class);
+ assertEquals(CMD_DATETIME_SYNC,decoded.command);
+ assertEquals("CCCCDDDDEEEEFFFF",decoded.gatewayId );
+ }
+
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.linktap.internal.LinkTapBindingConstants;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_DATETIME_READ;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_DATETIME_SYNC;
+
+/**
+ * Command 14: Read Gateway time
+ * Flow 1 --> App->Broker->GW: Request from Gateway its current system time
+ */
+@NonNullByDefault
+public class Command14Test {
+
+ /**
+ * Command 14: Read Gateway time
+ * Flow 1 --> App->Broker->GW: Request from Gateway its current system time
+ */
+ @Test
+ public void ResponseForGwTimeRequestGenerationTest() {
+ HandshakeResp req = new HandshakeResp();
+ req.gatewayId = "CCCCDDDDEEEEFFFF";
+ req.command = CMD_DATETIME_READ;
+ req.date = "20210501";
+ req.time = "123055";
+ req.wday = 6;
+
+ String encoded = LinkTapBindingConstants.GSON.toJson(req);
+
+ assertEquals("{\"date\":\"20210501\",\"time\":\"123055\",\"wday\":6,\"cmd\":14,\"gw_id\":\"CCCCDDDDEEEEFFFF\"}",
+ encoded);
+ }
+
+ /**
+ * Command 14: Read Gateway time
+ * Flow 1 --> App->Broker->GW: Request from Gateway its current system time
+ */
+ @Test
+ public void RequestForGwTimeDecoding() {
+ final TLGatewayFrame decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":14, \"gw_id\":\"CCCCDDDDEEEEFFFF\"\n}",EndpointDeviceResponse.class);
+ assertEquals(CMD_DATETIME_READ,decoded.command);
+ assertEquals("CCCCDDDDEEEEFFFF",decoded.gatewayId );
+ }
+
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.linktap.internal.LinkTapBindingConstants;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_DATETIME_SYNC;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_WIRELESS_CHECK;
+
+/**
+ * Command 15: Test wireless performance of end device
+ * Flow 1 --> App->Broker->GW: Request a ping pong test is done to measure wireless performance for a end device
+ */
+@NonNullByDefault
+public class Command15Test {
+
+ /**
+ * Command 15: Test wireless performance of end device
+ * Flow 1 --> App->Broker->GW: Request a ping pong test is done to measure wireless performance for a end device
+ */
+ @Test
+ public void RequestWirelessCheckGenerationTest() {
+ DeviceCmdReq req = new DeviceCmdReq();
+ req.gatewayId = "CCCCDDDDEEEEFFFF";
+ req.deviceId = "1111222233334444";
+ req.command = CMD_WIRELESS_CHECK;
+
+ String encoded = LinkTapBindingConstants.GSON.toJson(req);
+
+ assertEquals("{\"dev_id\":\"1111222233334444\",\"cmd\":15,\"gw_id\":\"CCCCDDDDEEEEFFFF\"}",
+ encoded);
+ }
+
+ /**
+ * Command 15: Test wireless performance of end device
+ * Flow 1 --> App->Broker->GW: Request a ping pong test is done to measure wireless performance for a end device response
+ */
+ @Test
+ public void RequestWirelessCheckResponseDecoding() {
+ final WirelessTestResp decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":15, \"gw_id\":\"CCCCDDDDEEEEFFFF\", \"dev_id\":\"1111222233334444\", \"ret\":0, \"final\":false, \"ping\":5, \"pong\":4\n}",WirelessTestResp.class);
+ assertEquals(CMD_WIRELESS_CHECK,decoded.command);
+ assertEquals("CCCCDDDDEEEEFFFF",decoded.gatewayId );
+ assertEquals("1111222233334444",decoded.deviceId );
+ assertEquals(GatewayDeviceResponse.ResultStatus.RET_SUCCESS,decoded.getRes());
+ assertFalse(decoded.testComplete);
+ assertEquals(5, decoded.pingCount);
+ assertEquals(4, decoded.pongCount);
+ }
+
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.linktap.internal.LinkTapBindingConstants;
+
+import java.util.Arrays;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_GET_CONFIGURATION;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_WIRELESS_CHECK;
+
+/**
+ * Command 16: Get gateway configuration
+ * Flow 1 --> App->Broker->GW: Request the configuration of the Gateway
+ */
+@NonNullByDefault
+public class Command16Test {
+
+ /**
+ * Command 16: Get gateway configuration
+ * Flow 1 --> App->Broker->GW: Request the configuration of the Gateway
+ */
+ @Test
+ public void RequestGatewayConfigGenerationTest() {
+ TLGatewayFrame req = new TLGatewayFrame();
+ req.gatewayId = "CCCCDDDDEEEEFFFF";
+ req.command = CMD_GET_CONFIGURATION;
+
+ String encoded = LinkTapBindingConstants.GSON.toJson(req);
+
+ assertEquals("{\"cmd\":16,\"gw_id\":\"CCCCDDDDEEEEFFFF\"}",
+ encoded);
+ }
+
+ /**
+ * Command 16: Get gateway configuration
+ * Flow 1 --> App->Broker->GW: Request the configuration of the Gateway
+ */
+ @Test
+ public void RequestGatewayConfigResponseDecoding() {
+ final GatewayConfigResp decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":16, \"gw_id\":\"1234E607004B1200\", \"ver\":\"G0608062305191832I\", \"vol_unit\":\"gal\", \"end_dev\":[ \"1234A923004B1200\", \"56787022004B1200\", \"ABCD6D13004B1200\"], \"dev_name\":[ \"Name_Of_Device_1234A923004B1200\", \"Name_Of_Device_56787022004B1200\", \"Name_Of_Device_ABCD6D13004B1200\"] }",GatewayConfigResp.class);
+ assertEquals(CMD_GET_CONFIGURATION,decoded.command);
+ assertEquals("1234E607004B1200",decoded.gatewayId );
+ assertEquals("G0608062305191832I",decoded.version );
+ assertEquals("gal", decoded.volumeUnit);
+ assertEquals(3, decoded.endDevices.length);
+ assertTrue(Arrays.asList(decoded.endDevices).contains("1234A923004B1200"));
+ assertTrue(Arrays.asList(decoded.endDevices).contains("56787022004B1200"));
+ assertTrue(Arrays.asList(decoded.endDevices).contains("ABCD6D13004B1200"));
+ assertEquals(3, decoded.deviceNames.length);
+ assertTrue(Arrays.asList(decoded.deviceNames).contains("Name_Of_Device_1234A923004B1200"));
+ assertTrue(Arrays.asList(decoded.deviceNames).contains("Name_Of_Device_56787022004B1200"));
+ assertTrue(Arrays.asList(decoded.deviceNames).contains("Name_Of_Device_ABCD6D13004B1200"));
+ }
+
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.linktap.internal.LinkTapBindingConstants;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.openhab.binding.linktap.protocol.frames.SetDeviceConfigReq.CONFIG_VOLUME_LIMIT;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_PAUSE_WATER_PLAN;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_SET_CONFIGURATION;
+
+/**
+ * Command 17: Set Device Configuration parameter
+ * Flow 1 --> App->Broker->GW: Sets the given device configuration parameter
+ */
+@NonNullByDefault
+public class Command17Test {
+
+ /**
+ * Command 17: Set Device Configuration parameter
+ * Flow 1 --> App->Broker->GW: Sets the given device configuration parameter
+ */
+ @Test
+ public void RequestConfigUpdateGenerationTest() {
+ SetDeviceConfigReq req = new SetDeviceConfigReq();
+ req.gatewayId = "CCCCDDDDEEEEFFFF";
+ req.deviceId = "1111222233334444";
+ req.command = CMD_SET_CONFIGURATION;
+ req.tag = CONFIG_VOLUME_LIMIT;
+ req.value = 123;
+
+ String encoded = LinkTapBindingConstants.GSON.toJson(req);
+
+ assertEquals("{\"value\":123,\"tag\":\"volume_limit\",\"dev_id\":\"1111222233334444\",\"cmd\":17,\"gw_id\":\"CCCCDDDDEEEEFFFF\"}",
+ encoded);
+ }
+
+ /**
+ * Command 17: Set Device Configuration parameter
+ * Flow 1 --> App->Broker->GW: Sets the given device configuration parameter
+ */
+ @Test
+ public void RequestConfigUpdateResponseDecoding() {
+ final EndpointDeviceResponse decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":17, \"gw_id\":\"CCCCDDDDEEEEFFFF\", \"dev_id\":\"1122334455667788\", \"ret\":0\n}",EndpointDeviceResponse.class);
+ assertEquals(CMD_SET_CONFIGURATION,decoded.command);
+ assertEquals("CCCCDDDDEEEEFFFF",decoded.gatewayId );
+ assertEquals("1122334455667788",decoded.deviceId );
+ assertEquals(GatewayDeviceResponse.ResultStatus.RET_SUCCESS,decoded.getRes());
+ }
+
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.linktap.internal.LinkTapBindingConstants;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_PAUSE_WATER_PLAN;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_WIRELESS_CHECK;
+
+/**
+ * Command 18: Pause watering plan for given duration
+ * Flow 1 --> App->Broker->GW: Pause watering plan for given duration 0.1 -> 240 hours
+ */
+@NonNullByDefault
+public class Command18Test {
+
+ /**
+ * Command 18: Pause watering plan for given duration
+ * Flow 1 --> App->Broker->GW: Pause watering plan for given duration 0.1 -> 240 hours
+ */
+ @Test
+ public void RequestWateringPlanPauseGenerationTest() {
+ PauseWateringPlanReq req = new PauseWateringPlanReq();
+ req.gatewayId = "CCCCDDDDEEEEFFFF";
+ req.deviceId = "1111222233334444";
+ req.command = CMD_PAUSE_WATER_PLAN;
+ req.duration = 12d;
+
+ String encoded = LinkTapBindingConstants.GSON.toJson(req);
+
+ assertEquals("{\"duration\":12.0,\"dev_id\":\"1111222233334444\",\"cmd\":18,\"gw_id\":\"CCCCDDDDEEEEFFFF\"}",
+ encoded);
+ }
+
+ /**
+ * Command 18: Pause watering plan for given duration
+ * Flow 1 --> App->Broker->GW: Pause watering plan for given duration 0.1 -> 240 hours
+ */
+ @Test
+ public void RequestWateringPlanPauseResponseDecoding() {
+ final EndpointDeviceResponse decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":18, \"gw_id\":\"CCCCDDDDEEEEFFFF\", \"dev_id\":\"1122334455667788\", \"ret\":0\n}",EndpointDeviceResponse.class);
+ assertEquals(CMD_PAUSE_WATER_PLAN,decoded.command);
+ assertEquals("CCCCDDDDEEEEFFFF",decoded.gatewayId );
+ assertEquals("1122334455667788",decoded.deviceId );
+ assertEquals(GatewayDeviceResponse.ResultStatus.RET_SUCCESS,decoded.getRes());
+ }
+
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.linktap.internal.LinkTapBindingConstants;
+
+import java.util.Arrays;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_ADD_END_DEVICE;
+
+/**
+ * Command 1: Add / Register Endpoint Device to Gateway
+ * Flow 1 --> App->Broker->GW: Add specified water timer to gateway
+ */
+@NonNullByDefault
+public class Command1Test {
+
+ /**
+ * Command 1:
+ * Flow 1 --> App->Broker->GW: Add specified water timer to gateway encoding test
+ */
+ @Test
+ public void AddDeviceRequestEncoding() {
+ final GatewayEndDevListReq req = new GatewayEndDevListReq();
+ req.command = CMD_ADD_END_DEVICE;
+ req.gatewayId = "CCCCDDDDEEEEFFFF";
+ req.endDevices = new String[] {"11112222333344448888","77778889333366661111"};
+
+ String encoded = LinkTapBindingConstants.GSON.toJson(req);
+
+ assertEquals("{\"end_dev\":[\"11112222333344448888\",\"77778889333366661111\"],\"cmd\":1,\"gw_id\":\"CCCCDDDDEEEEFFFF\"}",
+ encoded);
+ }
+
+ /**
+ * Command 1:
+ * Flow 1 --> App->Broker->GW: Add specified water timer to gateway response decoding test
+ */
+ @Test
+ public void AddDeviceRequestResponseDecoding() {
+ final GatewayDeviceResponse decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":1, \"gw_id\":\"CCCCDDDDEEEEFFFF\", \"ret\":0\n" +
+ "}",GatewayDeviceResponse.class);
+
+ assertEquals(CMD_ADD_END_DEVICE,decoded.command);
+ assertEquals("CCCCDDDDEEEEFFFF",decoded.gatewayId );
+ assertEquals(GatewayDeviceResponse.ResultStatus.RET_SUCCESS,decoded.getRes());
+ }
+
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.linktap.internal.LinkTapBindingConstants;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_REMOVE_END_DEVICE;
+
+/**
+ * Command 2: Delete / Unregister Endpoint Device to Gateway
+ * Flow 1 --> App->Broker->GW: Delete specified water timer to gateway
+ */
+@NonNullByDefault
+public class Command2Test {
+
+ /**
+ * Command 1:
+ * Flow 1 --> App->Broker->GW: Delete specified water timer to gateway encoding test
+ */
+ @Test
+ public void DeleteDeviceRequestEncoding() {
+ final GatewayEndDevListReq req = new GatewayEndDevListReq();
+ req.command = CMD_REMOVE_END_DEVICE;
+ req.gatewayId = "CCCCDDDDEEEEFFFF";
+ req.endDevices = new String[] {"1111222233334444","7777888933336666"};
+
+ String encoded = LinkTapBindingConstants.GSON.toJson(req);
+
+ assertEquals("{\"end_dev\":[\"1111222233334444\",\"7777888933336666\"],\"cmd\":2,\"gw_id\":\"CCCCDDDDEEEEFFFF\"}",
+ encoded);
+ }
+
+ /**
+ * Command 2:
+ * Flow 2 --> App->Broker->GW: Delete specified water timer to gateway response decoding test
+ */
+ @Test
+ public void DeleteDeviceRequestResponseDecoding() {
+ final GatewayDeviceResponse decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":2, \"gw_id\":\"CCCCDDDDEEEEFFFF\", \"ret\":0\n}",GatewayDeviceResponse.class);
+
+ assertEquals(CMD_REMOVE_END_DEVICE,decoded.command);
+ assertEquals("CCCCDDDDEEEEFFFF",decoded.gatewayId );
+ assertEquals(GatewayDeviceResponse.ResultStatus.RET_SUCCESS,decoded.getRes());
+ }
+
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.linktap.internal.LinkTapBindingConstants;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_UPDATE_WATER_TIMER_STATUS;
+
+/**
+ * Command 3: Water Timer Status Update Notification
+ * Flow 1 --> GW->Broker->App: Notification that there is a update to one or more water timer's
+ * Default format --> object's within an array
+ * Optional format --> object not wrapped within an array
+ * Flow 2 --> App->Broker->GW: Request Water Timer Status
+ *
+ * (ret is only provided in case of an error so -1 would be the same as if 0 was provided)
+ */
+@NonNullByDefault
+public class Command3Test {
+
+ /**
+ * Command 3: Water Timer Status Update Notification
+ * Flow 1 --> GW->Broker->App: Notification that there is a update to one or more water timer's
+ * Default format --> object's within an array
+ * Optional format --> object not wrapped within an array
+ * Flow 2 --> App->Broker->GW: Request Water Timer Status
+ */
+ @Test
+ public void NotificationTimerUpdateRequest1Decoding() {
+ final WaterMeterStatus decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":3, \"gw_id\":\"CCCCDDDDEEEEFFFF\", \"dev_stat\": [ { \"dev_id\":\"1111222233334444\", \"plan_mode\":2, \"plan_sn\":3134, \"is_rf_linked\":true, \"is_flm_plugin\":false, \"is_fall\":false, \"is_broken\":false, \"is_cutoff\":false, \"is_leak\":false, \"is_clog\":false, \"signal\":100, \"battery\":0, \"child_lock\":0, \"is_manual_mode\":false, \"is_watering\":false, \"is_final\":true, \"total_duration\":0, \"remain_duration\":0, \"speed\":0, \"volume\":0\n} ]\n}",WaterMeterStatus.class);
+
+ assertEquals(CMD_UPDATE_WATER_TIMER_STATUS,decoded.command);
+ assertEquals("CCCCDDDDEEEEFFFF",decoded.gatewayId );
+ assertNotNull(decoded.deviceStatuses);
+ assertEquals(1,decoded.deviceStatuses.size());
+ assertEquals("1111222233334444",decoded.deviceStatuses.get(0).deviceId);
+ assertEquals(2,decoded.deviceStatuses.get(0).planMode);
+ assertEquals(3134,decoded.deviceStatuses.get(0).planSerialNo);
+ assertTrue(decoded.deviceStatuses.get(0).isRfLinked);
+ assertFalse(decoded.deviceStatuses.get(0).isFlmPlugin);
+ assertFalse(decoded.deviceStatuses.get(0).isBroken);
+ assertFalse(decoded.deviceStatuses.get(0).isCutoff);
+ assertFalse(decoded.deviceStatuses.get(0).isLeak);
+ assertFalse(decoded.deviceStatuses.get(0).isClog);
+ assertEquals(100,decoded.deviceStatuses.get(0).signal);
+ assertEquals(0,decoded.deviceStatuses.get(0).battery);
+ assertEquals(0,decoded.deviceStatuses.get(0).childLock);
+ assertFalse(decoded.deviceStatuses.get(0).isManualMode);
+ assertFalse(decoded.deviceStatuses.get(0).isWatering);
+ assertTrue(decoded.deviceStatuses.get(0).isFinal);
+ assertEquals(0,decoded.deviceStatuses.get(0).totalDuration);
+ assertEquals(0,decoded.deviceStatuses.get(0).remainDuration);
+ assertEquals(0,decoded.deviceStatuses.get(0).speed);
+ assertEquals(0,decoded.deviceStatuses.get(0).volume);
+ assertEquals(GatewayDeviceResponse.ResultStatus.RET_SUCCESS,decoded.getRes()); // Only given in case of error
+ assertEquals(GatewayDeviceResponse.ResultStatus.RET_SUCCESS,decoded.getRes());
+ }
+
+ /**
+ * Command 3: Water Timer Status Update Notification
+ * Flow 1 --> GW->Broker->App: Notification that there is a update to one water timer
+ * Optional format --> object's without array wrapper
+ */
+ @Test
+ public void NotificationTimerUpdateRequest2Decoding() {
+ final WaterMeterStatus decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":3, \"gw_id\":\"CCCCDDDDEEEEFFFF\", \"dev_stat\": { \"dev_id\":\"1111222233334444\", \"plan_mode\":2, \"plan_sn\":3134, \"is_rf_linked\":true, \"is_flm_plugin\":false, \"is_fall\":false, \"is_broken\":false, \"is_cutoff\":false, \"is_leak\":false, \"is_clog\":false, \"signal\":100, \"battery\":0, \"child_lock\":0, \"is_manual_mode\":false, \"is_watering\":false, \"is_final\":true, \"total_duration\":0, \"remain_duration\":0, \"speed\":0, \"volume\":0\n}\n}",WaterMeterStatus.class);
+
+ assertEquals(CMD_UPDATE_WATER_TIMER_STATUS,decoded.command);
+ assertEquals("CCCCDDDDEEEEFFFF",decoded.gatewayId );
+ assertEquals(1,decoded.deviceStatuses.size());
+ assertEquals("1111222233334444",decoded.deviceStatuses.get(0).deviceId);
+ assertEquals(2,decoded.deviceStatuses.get(0).planMode);
+ assertEquals(3134,decoded.deviceStatuses.get(0).planSerialNo);
+ assertTrue(decoded.deviceStatuses.get(0).isRfLinked);
+ assertFalse(decoded.deviceStatuses.get(0).isFlmPlugin);
+ assertFalse(decoded.deviceStatuses.get(0).isBroken);
+ assertFalse(decoded.deviceStatuses.get(0).isCutoff);
+ assertFalse(decoded.deviceStatuses.get(0).isLeak);
+ assertFalse(decoded.deviceStatuses.get(0).isClog);
+ assertEquals(100,decoded.deviceStatuses.get(0).signal);
+ assertEquals(0,decoded.deviceStatuses.get(0).battery);
+ assertEquals(0,decoded.deviceStatuses.get(0).childLock);
+ assertFalse(decoded.deviceStatuses.get(0).isManualMode);
+ assertFalse(decoded.deviceStatuses.get(0).isWatering);
+ assertTrue(decoded.deviceStatuses.get(0).isFinal);
+ assertEquals(0,decoded.deviceStatuses.get(0).totalDuration);
+ assertEquals(0,decoded.deviceStatuses.get(0).remainDuration);
+ assertEquals(0,decoded.deviceStatuses.get(0).speed);
+ assertEquals(0,decoded.deviceStatuses.get(0).volume);
+ assertEquals(GatewayDeviceResponse.ResultStatus.RET_SUCCESS,decoded.getRes()); // Only given in case of error
+ assertEquals(GatewayDeviceResponse.ResultStatus.RET_SUCCESS,decoded.getRes());
+ }
+
+ /**
+ * Command 3: Water Timer Status Update Notification
+ * Flow 2 --> App->Broker->GW: Request Water Timer Status
+ */
+ @Test
+ public void RequestWaterMeterStatusGenerationTest() {
+ DeviceCmdReq req = new DeviceCmdReq();
+ req.gatewayId = "CCCCDDDDEEEEFFFF";
+ req.deviceId = "1111222233334444";
+ req.command = CMD_UPDATE_WATER_TIMER_STATUS;
+
+ String encoded = LinkTapBindingConstants.GSON.toJson(req);
+
+ assertEquals("{\"dev_id\":\"1111222233334444\",\"cmd\":3,\"gw_id\":\"CCCCDDDDEEEEFFFF\"}",
+ encoded);
+ }
+
+ /**
+ * Command 3: Water Timer Status Update Notification
+ * Flow 2 --> App->Broker->GW: Request Water Timer Status
+ */
+ @Test
+ public void RequestWaterMeterStatusErrorResponseDecoding() {
+ final WaterMeterStatus decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":3, \"gw_id\":\"CCCCDDDDEEEEFFFF\", \"ret\":5\n}",WaterMeterStatus.class);
+
+ assertEquals(CMD_UPDATE_WATER_TIMER_STATUS,decoded.command);
+ assertEquals("CCCCDDDDEEEEFFFF",decoded.gatewayId );
+ assertEquals(GatewayDeviceResponse.ResultStatus.RET_DEVICE_NOT_FOUND,decoded.getRes());
+ }
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.linktap.internal.LinkTapBindingConstants;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_REMOVE_WATER_PLAN;
+
+/**
+ * Command 5: Delete Watering Plan from Device Endpoint
+ * Flow 1 --> App->Broker->GW: Delete existing watering plan from device
+ */
+@NonNullByDefault
+public class Command5Test {
+
+ /**
+ * Command 5:
+ * Flow 1 --> App->Broker->GW: Delete existing watering plan from device encoding test
+ */
+ @Test
+ public void DeleteWateringPlanRequestEncoding() {
+ final DeviceCmdReq req = new DeviceCmdReq();
+ req.command = CMD_REMOVE_WATER_PLAN;
+ req.gatewayId = "CCCCDDDDEEEEFFFF";
+ req.deviceId = "1111222233334444";
+
+ String encoded = LinkTapBindingConstants.GSON.toJson(req);
+
+ assertEquals("{\"dev_id\":\"1111222233334444\",\"cmd\":5,\"gw_id\":\"CCCCDDDDEEEEFFFF\"}",
+ encoded);
+ }
+
+ /**
+ * Command 5:
+ * Flow 1 --> App->Broker->GW: Delete existing watering plan from device response decoding test
+ */
+ @Test
+ public void DeleteWateringPlanRequestResponseDecoding() {
+ final EndpointDeviceResponse decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":5, \"gw_id\":\"CCCCDDDDEEEEFFFF\", \"dev_id\":\"1111222233334444\", \"ret\":0\n}",EndpointDeviceResponse.class);
+
+ assertEquals(CMD_REMOVE_WATER_PLAN,decoded.command);
+ assertEquals("CCCCDDDDEEEEFFFF",decoded.gatewayId );
+ assertEquals("1111222233334444",decoded.deviceId);
+ assertEquals(GatewayDeviceResponse.ResultStatus.RET_SUCCESS,decoded.getRes());
+ }
+
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.linktap.internal.LinkTapBindingConstants;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_IMMEDIATE_WATER_START;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_IMMEDIATE_WATER_STOP;
+
+/**
+ * Command 6: Start watering immediately
+ * Flow 1 --> App->Broker->GW: Start watering immediately, once time only for the given parameters
+ */
+@NonNullByDefault
+public class Command6Test {
+
+ /**
+ * Command 6:
+ * Flow 1 --> App->Broker->GW: Start watering immediately, once time only for the given parameters
+ */
+ @Test
+ public void StartWateringRequestEncoding() {
+ final StartWateringReq req = new StartWateringReq();
+ req.command = CMD_IMMEDIATE_WATER_START;
+ req.gatewayId = "CCCCDDDDEEEEFFFF";
+ req.deviceId = "1111222233334444";
+ req.duration = 60;
+ req.volume = 0;
+
+ String encoded = LinkTapBindingConstants.GSON.toJson(req);
+
+ assertEquals("{\"duration\":60,\"volume\":0,\"dev_id\":\"1111222233334444\",\"cmd\":6,\"gw_id\":\"CCCCDDDDEEEEFFFF\"}",
+ encoded);
+ }
+
+ /**
+ * Command 6:
+ * Flow 1 --> App->Broker->GW: Start watering immediately, once time only for the given parameters
+ */
+ @Test
+ public void StartWateringRequestResponseDecoding() {
+ final EndpointDeviceResponse decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":6, \"gw_id\":\"CCCCDDDDEEEEFFFF\", \"dev_id\":\"1111222233334444\", \"ret\":0\n}",EndpointDeviceResponse.class);
+
+ assertEquals(CMD_IMMEDIATE_WATER_START,decoded.command);
+ assertEquals("CCCCDDDDEEEEFFFF",decoded.gatewayId );
+ assertEquals("1111222233334444",decoded.deviceId);
+ assertEquals(GatewayDeviceResponse.ResultStatus.RET_SUCCESS,decoded.getRes());
+ }
+
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.linktap.internal.LinkTapBindingConstants;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_IMMEDIATE_WATER_STOP;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_REMOVE_WATER_PLAN;
+
+/**
+ * Command 7: Stop watering immediately
+ * Flow 1 --> App->Broker->GW: Stop watering immediately, next cycled watering plan will still run
+ */
+@NonNullByDefault
+public class Command7Test {
+
+ /**
+ * Command 7:
+ * Flow 1 --> App->Broker->GW: Stop watering immediately, next cycled watering plan will still run
+ */
+ @Test
+ public void StopWateringRequestEncoding() {
+ final DeviceCmdReq req = new DeviceCmdReq();
+ req.command = CMD_IMMEDIATE_WATER_STOP;
+ req.gatewayId = "CCCCDDDDEEEEFFFF";
+ req.deviceId = "1111222233334444";
+
+ String encoded = LinkTapBindingConstants.GSON.toJson(req);
+
+ assertEquals("{\"dev_id\":\"1111222233334444\",\"cmd\":7,\"gw_id\":\"CCCCDDDDEEEEFFFF\"}",
+ encoded);
+ }
+
+ /**
+ * Command 7:
+ * Flow 1 --> App->Broker->GW: Stop watering immediately, next cycled watering plan will still run
+ */
+ @Test
+ public void StopWateringRequestResponseDecoding() {
+ final EndpointDeviceResponse decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":7, \"gw_id\":\"CCCCDDDDEEEEFFFF\", \"dev_id\":\"1111222233334444\", \"ret\":0\n}",EndpointDeviceResponse.class);
+
+ assertEquals(CMD_IMMEDIATE_WATER_STOP,decoded.command);
+ assertEquals("CCCCDDDDEEEEFFFF",decoded.gatewayId );
+ assertEquals("1111222233334444",decoded.deviceId);
+ assertEquals(GatewayDeviceResponse.ResultStatus.RET_SUCCESS,decoded.getRes());
+ }
+
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_RAINFALL_DATA;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.linktap.internal.LinkTapBindingConstants;
+
+/**
+ * Command 8: Rain Data
+ * Flow 1 --> GW->Broker->App: Request for rain data
+ * Flow 2 --> App->Broker->GW: Push to update rain data
+ */
+@NonNullByDefault
+public class Command8Test {
+
+ /**
+ * Command 8:
+ * Flow 1 --> GW->Broker->App: Request for Rain Data Decoding Test
+ */
+ @Test
+ public void RainDataRequestDecoding() {
+ final TLGatewayFrame decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":8, \"gw_id\":\"CCCCDDDDEEEEFFFF\"\n" +
+ "}",TLGatewayFrame.class);
+
+ assertEquals(CMD_RAINFALL_DATA,decoded.command);
+ assertEquals("CCCCDDDDEEEEFFFF",decoded.gatewayId );
+ }
+
+ /**
+ * Command 8:
+ * Flow 1 --> GW->Broker->App: Response serialisation test for Rain Data reply
+ */
+ @Test
+ public void RainDataRequestResponseGenerationTest() {
+ RainDataForecast forecastReply = new RainDataForecast();
+ forecastReply.command = CMD_RAINFALL_DATA;
+ forecastReply.gatewayId = "CCCCDDDDEEEEFFFF";
+ forecastReply.setPastRainfall(2.5);
+ forecastReply.setFutureRainfall(6.3);
+ forecastReply.validDuration = 60;
+
+ String encoded = LinkTapBindingConstants.GSON.toJson(forecastReply);
+
+ assertEquals("{\"valid_duration\":60,\"rain\":[2.5,6.3],\"cmd\":8,\"gw_id\":\"CCCCDDDDEEEEFFFF\"}",
+ encoded);
+ }
+
+ /**
+ * Command 8:
+ * Flow 2 --> App->Broker->GW: Push of Rain Data to Gateway request serialisation
+ */
+ @Test
+ public void RainDataPushGenerationTest() {
+ RainDataForecast req = new RainDataForecast();
+ req.command = CMD_RAINFALL_DATA;
+ req.gatewayId = "CCCCDDDDEEEEFFFF";
+ req.setPastRainfall(2.5);
+ req.setFutureRainfall(6.3);
+ req.validDuration = 60;
+
+ String encoded = LinkTapBindingConstants.GSON.toJson(req);
+
+ assertEquals("{\"valid_duration\":60,\"rain\":[2.5,6.3],\"cmd\":8,\"gw_id\":\"CCCCDDDDEEEEFFFF\"}",
+ encoded);
+ }
+
+ /**
+ * Command 8:
+ * Flow 2 --> App->Broker->GW: Response decoding test for Rain Data Push reply
+ */
+ @Test
+ public void RainDataPushResponseDecoding() {
+ final GatewayDeviceResponse decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":8, \"gw_id\":\"CCCCDDDDEEEEFFFF\", \"ret\":0\n" +
+ "}",GatewayDeviceResponse.class);
+
+ assertEquals(CMD_RAINFALL_DATA,decoded.command);
+ assertEquals("CCCCDDDDEEEEFFFF",decoded.gatewayId );
+ assertEquals(GatewayDeviceResponse.ResultStatus.RET_SUCCESS,decoded.getRes());
+ }
+
+
+}
--- /dev/null
+/**
+ * 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.linktap.protocol.frames;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.linktap.internal.LinkTapBindingConstants;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.openhab.binding.linktap.protocol.frames.TLGatewayFrame.CMD_NOTIFICATION_WATERING_SKIPPED;
+
+/**
+ * Command 9: Watering Skipped Notification
+ * Flow 1 --> App->Broker->GW: Notification that watering was skipped with the rainfall data
+ */
+@NonNullByDefault
+public class Command9Test {
+
+ /**
+ * Command 9:
+ * Flow 1 --> GW->Broker->App: Notification that watering was skipped with the rainfall data
+ */
+ @Test
+ public void NotificationWateringSkippedRequestDecoding() {
+ final WateringSkippedNotification decoded = LinkTapBindingConstants.GSON.fromJson("{ \"cmd\":9, \"gw_id\":\"CCCCDDDDEEEEFFFF\",\n\"dev_id\":\"1111222233334444\", \"rain\":[2.5,6.3]\n}",WateringSkippedNotification.class);
+
+ assertNotNull(decoded);
+ assertEquals(CMD_NOTIFICATION_WATERING_SKIPPED,decoded.command);
+ assertEquals("CCCCDDDDEEEEFFFF",decoded.gatewayId );
+ assertEquals("1111222233334444", decoded.deviceId);
+ assertEquals(2, decoded.rainfallData.length);
+ assertEquals(2.5,decoded.rainfallData[0]);
+ assertEquals(6.3,decoded.rainfallData[1]);
+ }
+}
<module>org.openhab.binding.lifx</module>
<module>org.openhab.binding.linky</module>
<module>org.openhab.binding.linuxinput</module>
+ <module>org.openhab.binding.linktap</module>
<module>org.openhab.binding.liquidcheck</module>
<module>org.openhab.binding.lirc</module>
<module>org.openhab.binding.livisismarthome</module>