]> git.basschouten.com Git - openhab-addons.git/commitdiff
[linktap] Initial contribution (#17235)
authordag81 <dag81@users.noreply.github.com>
Mon, 30 Sep 2024 03:48:55 +0000 (04:48 +0100)
committerGitHub <noreply@github.com>
Mon, 30 Sep 2024 03:48:55 +0000 (05:48 +0200)
* [linkTap] Initial Code Commit

[Signed-off-by: dag81 <david.goodyear@gmail.com>

78 files changed:
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.linktap/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.linktap/README.md [new file with mode: 0644]
bundles/org.openhab.binding.linktap/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/configuration/LinkTapBridgeConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/configuration/LinkTapDeviceConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/DeviceMetaDataUpdatedHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/Firmware.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/IBridgeData.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapBridgeDiscoveryService.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapBridgeHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapDeviceDiscoveryService.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapDeviceMetadata.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LookupWrapper.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/PollingDeviceHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/TransactionProcessor.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/Utils.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/AlertStateReq.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/DeviceCmdReq.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/DismissAlertReq.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/EndpointDeviceResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/GatewayConfigResp.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/GatewayDeviceResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/GatewayEndDevListReq.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/HandshakeReq.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/HandshakeResp.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/IPayloadValidator.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/LockReq.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/PauseWateringPlanReq.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/RainData.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/RainDataForecast.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/SetDeviceConfigReq.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/SetupWaterPlan.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/StartWateringReq.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/TLGatewayFrame.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/TimeDataResp.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/ValidationError.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/WaterMeterStatus.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/WateringSkippedNotification.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/WirelessTestResp.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/CommandNotSupportedException.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/DeviceIdException.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/GatewayIdException.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/I18Exception.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/InvalidParameterException.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/LinkTapException.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/NotTapLinkGatewayException.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/TransientCommunicationIssueException.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/WebServerApi.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/servers/BindingServlet.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/servers/IHttpClientProvider.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/resources/OH-INF/addon/addon.xml [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/resources/OH-INF/i18n/linktap.properties [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/resources/OH-INF/thing/channel-types.xml [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/resources/OH-INF/thing/thing-types.xml [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command0Test.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command10Test.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command11Test.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command12Test.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command13Test.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command14Test.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command15Test.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command16Test.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command17Test.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command18Test.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command1Test.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command2Test.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command3Test.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command5Test.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command6Test.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command7Test.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command8Test.java [new file with mode: 0644]
bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command9Test.java [new file with mode: 0644]
bundles/pom.xml

index 3a3e20519aec80dad2142c8b22041b639094ab1b..22b4e2b1f2f8956a8d35715779992186fac1c9be 100644 (file)
       <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>
diff --git a/bundles/org.openhab.binding.linktap/NOTICE b/bundles/org.openhab.binding.linktap/NOTICE
new file mode 100644 (file)
index 0000000..3e2c49e
--- /dev/null
@@ -0,0 +1,20 @@
+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
diff --git a/bundles/org.openhab.binding.linktap/README.md b/bundles/org.openhab.binding.linktap/README.md
new file mode 100644 (file)
index 0000000..2e0099e
--- /dev/null
@@ -0,0 +1,234 @@
+# 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.
+
diff --git a/bundles/org.openhab.binding.linktap/pom.xml b/bundles/org.openhab.binding.linktap/pom.xml
new file mode 100644 (file)
index 0000000..a9987b9
--- /dev/null
@@ -0,0 +1,36 @@
+<?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>
diff --git a/bundles/org.openhab.binding.linktap/src/main/feature/feature.xml b/bundles/org.openhab.binding.linktap/src/main/feature/feature.xml
new file mode 100644 (file)
index 0000000..d84db90
--- /dev/null
@@ -0,0 +1,10 @@
+<?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>
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/configuration/LinkTapBridgeConfiguration.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/configuration/LinkTapBridgeConfiguration.java
new file mode 100644 (file)
index 0000000..800874e
--- /dev/null
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.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;
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/configuration/LinkTapDeviceConfiguration.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/configuration/LinkTapDeviceConfiguration.java
new file mode 100644 (file)
index 0000000..0b32e63
--- /dev/null
@@ -0,0 +1,40 @@
+/**
+ * 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;
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/DeviceMetaDataUpdatedHandler.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/DeviceMetaDataUpdatedHandler.java
new file mode 100644 (file)
index 0000000..865ba67
--- /dev/null
@@ -0,0 +1,32 @@
+/**
+ * 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);
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/Firmware.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/Firmware.java
new file mode 100644 (file)
index 0000000..9801554
--- /dev/null
@@ -0,0 +1,68 @@
+/**
+ * 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);
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/IBridgeData.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/IBridgeData.java
new file mode 100644 (file)
index 0000000..d390158
--- /dev/null
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.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();
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapBindingConstants.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapBindingConstants.java
new file mode 100644 (file)
index 0000000..b266f7c
--- /dev/null
@@ -0,0 +1,192 @@
+/**
+ * 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);
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapBridgeDiscoveryService.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapBridgeDiscoveryService.java
new file mode 100644 (file)
index 0000000..edae73b
--- /dev/null
@@ -0,0 +1,265 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapBridgeHandler.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapBridgeHandler.java
new file mode 100644 (file)
index 0000000..2030f4a
--- /dev/null
@@ -0,0 +1,634 @@
+/**
+ * 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);
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapDeviceDiscoveryService.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapDeviceDiscoveryService.java
new file mode 100644 (file)
index 0000000..ed8ffb3
--- /dev/null
@@ -0,0 +1,102 @@
+/**
+ * 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);
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapDeviceMetadata.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapDeviceMetadata.java
new file mode 100644 (file)
index 0000000..972eede
--- /dev/null
@@ -0,0 +1,39 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapHandler.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapHandler.java
new file mode 100644 (file)
index 0000000..f398293
--- /dev/null
@@ -0,0 +1,464 @@
+/**
+ * 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());
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapHandlerFactory.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LinkTapHandlerFactory.java
new file mode 100644 (file)
index 0000000..9953190
--- /dev/null
@@ -0,0 +1,95 @@
+/**
+ * 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();
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LookupWrapper.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/LookupWrapper.java
new file mode 100644 (file)
index 0000000..c9246b2
--- /dev/null
@@ -0,0 +1,89 @@
+/**
+ * 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);
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/PollingDeviceHandler.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/PollingDeviceHandler.java
new file mode 100644 (file)
index 0000000..92bfed5
--- /dev/null
@@ -0,0 +1,339 @@
+/**
+ * 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();
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/TransactionProcessor.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/TransactionProcessor.java
new file mode 100644 (file)
index 0000000..b2bae22
--- /dev/null
@@ -0,0 +1,344 @@
+/**
+ * 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);
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/Utils.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/internal/Utils.java
new file mode 100644 (file)
index 0000000..8afa6e8
--- /dev/null
@@ -0,0 +1,73 @@
+/**
+ * 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();
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/AlertStateReq.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/AlertStateReq.java
new file mode 100644 (file)
index 0000000..2b162a3
--- /dev/null
@@ -0,0 +1,43 @@
+/**
+ * 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;
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/DeviceCmdReq.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/DeviceCmdReq.java
new file mode 100644 (file)
index 0000000..ec34a94
--- /dev/null
@@ -0,0 +1,55 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/DismissAlertReq.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/DismissAlertReq.java
new file mode 100644 (file)
index 0000000..1c12fb4
--- /dev/null
@@ -0,0 +1,86 @@
+/**
+ * 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;
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/EndpointDeviceResponse.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/EndpointDeviceResponse.java
new file mode 100644 (file)
index 0000000..6bed1dd
--- /dev/null
@@ -0,0 +1,50 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/GatewayConfigResp.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/GatewayConfigResp.java
new file mode 100644 (file)
index 0000000..81a5a46
--- /dev/null
@@ -0,0 +1,79 @@
+/**
+ * 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";
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/GatewayDeviceResponse.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/GatewayDeviceResponse.java
new file mode 100644 (file)
index 0000000..7f6efb4
--- /dev/null
@@ -0,0 +1,181 @@
+/**
+ * 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;
+     */
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/GatewayEndDevListReq.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/GatewayEndDevListReq.java
new file mode 100644 (file)
index 0000000..bf15dc4
--- /dev/null
@@ -0,0 +1,63 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/HandshakeReq.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/HandshakeReq.java
new file mode 100644 (file)
index 0000000..467997e
--- /dev/null
@@ -0,0 +1,51 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/HandshakeResp.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/HandshakeResp.java
new file mode 100644 (file)
index 0000000..67e3bb9
--- /dev/null
@@ -0,0 +1,89 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/IPayloadValidator.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/IPayloadValidator.java
new file mode 100644 (file)
index 0000000..574e6f6
--- /dev/null
@@ -0,0 +1,40 @@
+/**
+ * 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));
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/LockReq.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/LockReq.java
new file mode 100644 (file)
index 0000000..b7c767e
--- /dev/null
@@ -0,0 +1,68 @@
+/**
+ * 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;
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/PauseWateringPlanReq.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/PauseWateringPlanReq.java
new file mode 100644 (file)
index 0000000..e7c8617
--- /dev/null
@@ -0,0 +1,55 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/RainData.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/RainData.java
new file mode 100644 (file)
index 0000000..6c1b55d
--- /dev/null
@@ -0,0 +1,65 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/RainDataForecast.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/RainDataForecast.java
new file mode 100644 (file)
index 0000000..bd4cf89
--- /dev/null
@@ -0,0 +1,50 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/SetDeviceConfigReq.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/SetDeviceConfigReq.java
new file mode 100644 (file)
index 0000000..bbc74c7
--- /dev/null
@@ -0,0 +1,75 @@
+/**
+ * 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";
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/SetupWaterPlan.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/SetupWaterPlan.java
new file mode 100644 (file)
index 0000000..2a35bb2
--- /dev/null
@@ -0,0 +1,151 @@
+/**
+ * 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;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/StartWateringReq.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/StartWateringReq.java
new file mode 100644 (file)
index 0000000..09112e9
--- /dev/null
@@ -0,0 +1,68 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/TLGatewayFrame.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/TLGatewayFrame.java
new file mode 100644 (file)
index 0000000..400d2ca
--- /dev/null
@@ -0,0 +1,254 @@
+/**
+ * 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;
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/TimeDataResp.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/TimeDataResp.java
new file mode 100644 (file)
index 0000000..5d8ea7f
--- /dev/null
@@ -0,0 +1,28 @@
+/**
+ * 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() {
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/ValidationError.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/ValidationError.java
new file mode 100644 (file)
index 0000000..8870a92
--- /dev/null
@@ -0,0 +1,51 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/WaterMeterStatus.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/WaterMeterStatus.java
new file mode 100644 (file)
index 0000000..8481e71
--- /dev/null
@@ -0,0 +1,279 @@
+/**
+ * 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" };
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/WateringSkippedNotification.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/WateringSkippedNotification.java
new file mode 100644 (file)
index 0000000..f688e9f
--- /dev/null
@@ -0,0 +1,67 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/WirelessTestResp.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/frames/WirelessTestResp.java
new file mode 100644 (file)
index 0000000..5df7a91
--- /dev/null
@@ -0,0 +1,72 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/CommandNotSupportedException.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/CommandNotSupportedException.java
new file mode 100644 (file)
index 0000000..e29b078
--- /dev/null
@@ -0,0 +1,55 @@
+/**
+ * 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");
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/DeviceIdException.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/DeviceIdException.java
new file mode 100644 (file)
index 0000000..4d702f0
--- /dev/null
@@ -0,0 +1,58 @@
+/**
+ * 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");
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/GatewayIdException.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/GatewayIdException.java
new file mode 100644 (file)
index 0000000..eb3260c
--- /dev/null
@@ -0,0 +1,58 @@
+/**
+ * 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");
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/I18Exception.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/I18Exception.java
new file mode 100644 (file)
index 0000000..a11dd59
--- /dev/null
@@ -0,0 +1,58 @@
+/**
+ * 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);
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/InvalidParameterException.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/InvalidParameterException.java
new file mode 100644 (file)
index 0000000..c93f46b
--- /dev/null
@@ -0,0 +1,55 @@
+/**
+ * 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");
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/LinkTapException.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/LinkTapException.java
new file mode 100644 (file)
index 0000000..a25981b
--- /dev/null
@@ -0,0 +1,53 @@
+/**
+ * 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");
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/NotTapLinkGatewayException.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/NotTapLinkGatewayException.java
new file mode 100644 (file)
index 0000000..a17c8e8
--- /dev/null
@@ -0,0 +1,107 @@
+/**
+ * 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");
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/TransientCommunicationIssueException.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/TransientCommunicationIssueException.java
new file mode 100644 (file)
index 0000000..e634ad7
--- /dev/null
@@ -0,0 +1,93 @@
+/**
+ * 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");
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/WebServerApi.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/http/WebServerApi.java
new file mode 100644 (file)
index 0000000..35b3862
--- /dev/null
@@ -0,0 +1,572 @@
+/**
+ * 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);
+            }
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/servers/BindingServlet.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/servers/BindingServlet.java
new file mode 100644 (file)
index 0000000..b768b04
--- /dev/null
@@ -0,0 +1,163 @@
+/**
+ * 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();
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/servers/IHttpClientProvider.java b/bundles/org.openhab.binding.linktap/src/main/java/org/openhab/binding/linktap/protocol/servers/IHttpClientProvider.java
new file mode 100644 (file)
index 0000000..7be84b3
--- /dev/null
@@ -0,0 +1,33 @@
+/**
+ * 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();
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.linktap/src/main/resources/OH-INF/addon/addon.xml
new file mode 100644 (file)
index 0000000..7dad0e1
--- /dev/null
@@ -0,0 +1,29 @@
+<?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>
diff --git a/bundles/org.openhab.binding.linktap/src/main/resources/OH-INF/i18n/linktap.properties b/bundles/org.openhab.binding.linktap/src/main/resources/OH-INF/i18n/linktap.properties
new file mode 100644 (file)
index 0000000..29cd79b
--- /dev/null
@@ -0,0 +1,313 @@
+# 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
diff --git a/bundles/org.openhab.binding.linktap/src/main/resources/OH-INF/thing/channel-types.xml b/bundles/org.openhab.binding.linktap/src/main/resources/OH-INF/thing/channel-types.xml
new file mode 100644 (file)
index 0000000..1e6c9f7
--- /dev/null
@@ -0,0 +1,223 @@
+<?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>
diff --git a/bundles/org.openhab.binding.linktap/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.linktap/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644 (file)
index 0000000..7da700b
--- /dev/null
@@ -0,0 +1,119 @@
+<?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>
diff --git a/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command0Test.java b/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command0Test.java
new file mode 100644 (file)
index 0000000..ed55b55
--- /dev/null
@@ -0,0 +1,71 @@
+/**
+ * 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);
+    }
+
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command10Test.java b/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command10Test.java
new file mode 100644 (file)
index 0000000..09512f8
--- /dev/null
@@ -0,0 +1,64 @@
+/**
+ * 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());
+    }
+
+
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command11Test.java b/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command11Test.java
new file mode 100644 (file)
index 0000000..29a6797
--- /dev/null
@@ -0,0 +1,63 @@
+/**
+ * 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());
+    }
+
+
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command12Test.java b/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command12Test.java
new file mode 100644 (file)
index 0000000..8a4b1e9
--- /dev/null
@@ -0,0 +1,62 @@
+/**
+ * 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());
+    }
+    
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command13Test.java b/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command13Test.java
new file mode 100644 (file)
index 0000000..9dbe24d
--- /dev/null
@@ -0,0 +1,59 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.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 );
+    }
+    
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command14Test.java b/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command14Test.java
new file mode 100644 (file)
index 0000000..b1ea967
--- /dev/null
@@ -0,0 +1,60 @@
+/**
+ * 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 );
+    }
+    
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command15Test.java b/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command15Test.java
new file mode 100644 (file)
index 0000000..e4a4bfa
--- /dev/null
@@ -0,0 +1,64 @@
+/**
+ * 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);
+    }
+    
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command16Test.java b/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command16Test.java
new file mode 100644 (file)
index 0000000..61b2950
--- /dev/null
@@ -0,0 +1,71 @@
+/**
+ * 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"));
+    }
+    
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command17Test.java b/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command17Test.java
new file mode 100644 (file)
index 0000000..95cc64c
--- /dev/null
@@ -0,0 +1,63 @@
+/**
+ * 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());
+    }
+    
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command18Test.java b/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command18Test.java
new file mode 100644 (file)
index 0000000..861e83a
--- /dev/null
@@ -0,0 +1,62 @@
+/**
+ * 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());
+    }
+    
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command1Test.java b/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command1Test.java
new file mode 100644 (file)
index 0000000..5fafb07
--- /dev/null
@@ -0,0 +1,63 @@
+/**
+ * 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());
+    }
+
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command2Test.java b/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command2Test.java
new file mode 100644 (file)
index 0000000..63ee1df
--- /dev/null
@@ -0,0 +1,59 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.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());
+    }
+
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command3Test.java b/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command3Test.java
new file mode 100644 (file)
index 0000000..2937e51
--- /dev/null
@@ -0,0 +1,136 @@
+/**
+ * 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());
+    }
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command5Test.java b/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command5Test.java
new file mode 100644 (file)
index 0000000..eacc35b
--- /dev/null
@@ -0,0 +1,60 @@
+/**
+ * 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());
+    }
+
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command6Test.java b/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command6Test.java
new file mode 100644 (file)
index 0000000..acd15f6
--- /dev/null
@@ -0,0 +1,63 @@
+/**
+ * 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());
+    }
+
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command7Test.java b/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command7Test.java
new file mode 100644 (file)
index 0000000..2b6fd9a
--- /dev/null
@@ -0,0 +1,61 @@
+/**
+ * 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());
+    }
+
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command8Test.java b/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command8Test.java
new file mode 100644 (file)
index 0000000..7090c53
--- /dev/null
@@ -0,0 +1,96 @@
+/**
+ * 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());
+    }
+
+
+}
diff --git a/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command9Test.java b/bundles/org.openhab.binding.linktap/src/main/tests/org/openhab/binding/linktap/protocol/frames/Command9Test.java
new file mode 100644 (file)
index 0000000..2051ad4
--- /dev/null
@@ -0,0 +1,46 @@
+/**
+ * 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]);
+    }
+}
index 293021091b5fdd5bd81576ed43569477118d0f85..f8a7c1b0e3caa213f42e797971abca97a1fb8579 100644 (file)
     <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>