]> git.basschouten.com Git - openhab-addons.git/commitdiff
[veSync] New VeSync binding addition (#12219)
authordag81 <dag81@users.noreply.github.com>
Sat, 7 May 2022 10:15:48 +0000 (11:15 +0100)
committerGitHub <noreply@github.com>
Sat, 7 May 2022 10:15:48 +0000 (12:15 +0200)
* [veSync] New VeSync binding addition

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] New VeSync binding addition - LUH-D301S support added.

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] New VeSync binding addition - AH channel corrections

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] New VeSync binding addition - AH D301S night light removal

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] New VeSync binding addition - AH docs mistLevel correction

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] New VeSync binding addition - Debug output correction

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] New VeSync binding addition - Dual200S adjustments

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR adjustments

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR adjustments - removal of TODOs

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR adjustments - markdown table formatting

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR adjustments - Air Purifier doc's and bug fix

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR adjustments - Air Humidifiers doc's

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR adjustments - HttpClient handling management to move api instance to the correct location

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR adjustments - ThingTypeUID additions

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR adjustments - Documentation correction - airPurifierPollinterval

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR adjustments - Documentation correction - configuration parameters

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR adjustments - Module documentation correction - description update.

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR adjustments - thing-types - bridge configuration updates

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR adjustments - thing-types - description updates to cut length where possible.

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR adjustments - discovery - representation prop adjustments

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR adjustments - documentation - configuration block adjustments.

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR Adjustments - Humidity set point channel renames

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR Adjustments - Initalize direct call cleanup

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR Adjustments - getDeviceUID override removal

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR Adjustments - unit adjustments

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PMD Error correction - file naming correction

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR Adjustment - HttpClient handling simplified.

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR Adjustment - Removal of dead code.

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR Adjustment - Readme OpenHab to openHAB

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR Adjustment - Comment cleanup

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR Adjustment - Handler Error removal to comm issue

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR Adjustment - Quick spotless fix

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR Adjustment - Removal of debug log - as status has message in now.

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PPM to PM correction for Air Quality units.

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] ReadMe Units PM Update

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] Constant name correction for air quality units adjustment.

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] Humidifier Percentage Units addition.

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] Air Filter Life Remaining units addition

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PM25 update based on other bindings, to correct the units.

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] ReadMe PM25 updates

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR Updates: Thing Type Ids to lower case

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR: Removal of unrequired createThing override

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR: Removal of unused channel-type nightLightBrightnessType

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
* [veSync] PR: Readme correction

Signed-off-by: David Goodyear <david.goodyear@gmail.com>
50 files changed:
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.vesync/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.vesync/README.md [new file with mode: 0644]
bundles/org.openhab.binding.vesync/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/VeSyncBridgeConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/VeSyncConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/VeSyncDeviceConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/VeSyncHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/api/IHttpClientProvider.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/api/VeSyncV2ApiHelper.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/discovery/DeviceMetaDataUpdatedHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/discovery/VeSyncDiscoveryService.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/VeSyncBridgeConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncAuthenticatedRequest.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncLoginCredentials.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncProtocolConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncRequest.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncRequestManagedDeviceBypassV2.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncRequestManagedDevicesPage.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncRequestV1ManagedDeviceDetails.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncLoginResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncManagedDeviceBase.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncManagedDevicesPage.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncResponseManagedDeviceBypassV2.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncUserSession.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncV2BypassHumidifierStatus.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncV2BypassPurifierStatus.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/v1/VeSyncV1AirPurifierDeviceDetailsResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/exceptions/AuthenticationException.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/exceptions/DeviceUnknownException.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/handlers/VeSyncBaseDeviceHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/handlers/VeSyncBridgeHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/handlers/VeSyncClient.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/handlers/VeSyncDeviceAirHumidifierHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/handlers/VeSyncDeviceAirPurifierHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/resources/OH-INF/binding/binding.xml [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/resources/OH-INF/thing/thing-types.xml [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncAuthenticatedRequestTest.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncLoginCredentialsTest.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncRequestManagedDeviceBypassV2Test.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncRequestManagedDevicesPageTest.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncRequestTest.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncRequestV1ManagedDeviceDetailsTest.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/responses/VesyncLoginResponseTest.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/responses/VesyncManagedDevicesPageTest.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/responses/VesyncResponseTest.java [new file with mode: 0644]
bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/responses/v1/VesyncV1AirPurifierDeviceDetailsTest.java [new file with mode: 0644]
bundles/pom.xml

index 35085730c7181d69c62b9d3d0f68c80f3a692160..e4b65e93f829315ffa4fd698eebfd9e8b47d1d22 100644 (file)
       <artifactId>org.openhab.binding.verisure</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.binding.vesync</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>org.openhab.addons.bundles</groupId>
       <artifactId>org.openhab.binding.vigicrues</artifactId>
diff --git a/bundles/org.openhab.binding.vesync/NOTICE b/bundles/org.openhab.binding.vesync/NOTICE
new file mode 100644 (file)
index 0000000..38d625e
--- /dev/null
@@ -0,0 +1,13 @@
+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
diff --git a/bundles/org.openhab.binding.vesync/README.md b/bundles/org.openhab.binding.vesync/README.md
new file mode 100644 (file)
index 0000000..c8b0742
--- /dev/null
@@ -0,0 +1,303 @@
+# VeSync Binding
+
+Its current support is for the Air Purifiers & Humidifer's branded as Levoit which utilise the VeSync app based on the V2 protocol.
+
+### Verified Models
+
+Air Filtering models supported are Core300S, Core400S.
+Air Humidifier models supported are Dual 200S, Classic 300S, 600S.
+
+### Awaiting User Verification Models
+
+Air Filtering models supported are Core200S and Core600S.
+Air Humidifier Classic 200S (Same as 300S without the nightlight from initial checks)
+
+## Supported Things
+
+This binding supports the follow thing types:
+
+| Thing          | Thing Type | Thing Type UID | Discovery | Description                                                          |
+|----------------|------------|----------------|-----------|----------------------------------------------------------------------|
+| Bridge         | Bridge     | bridge         | Manual    | A single connection to the VeSync API                                |
+| Air Purifier   | Thing      | airPurifier    | Automatic | A Air Purifier supporting V2 e.g. Core200S/Core300S or Core400S unit |
+| Air Humidifier | Thing      | airHumidifier  | Automatic | A Air Humidifier supporting V2 e.g. Classic300S or 600s              |
+
+
+
+This binding was developed from the great work in the listed projects.
+
+The only Air Filter unit it has been tested against is the Core400S unit, **I'm looking for others to confirm** my queries regarding **the Core200S and Core300S** units.
+The ***Classic 300S Humidifier*** has been tested, and ***600S with current warm mode restrictions***.
+
+## Discovery
+
+Once the bridge is configured auto discovery will discover supported devices from the VeSync API.
+
+## Thing Configuration
+
+### Bridge configuration parameters
+
+| Name                             | Type   | Description                                               | Recommended Values |
+|----------------------------------|--------|-----------------------------------------------------------|--------------------|
+| username                         | String | The username as used in the VeSync mobile application     |                    |
+| password                         | String | The password as used in the VeSync mobile application     |                    |
+| airPurifierPollInterval          | Number | The poll interval (seconds) for air filters / humidifiers | 60                 |
+| backgroundDeviceDiscovery        | Switch | Should the system scan periodically for new devices       | ON                 |
+| refreshBackgroundDeviceDiscovery | Number | Frequency (seconds) of scans for new new devices          | 120                |
+
+* Note Air PM Levels don't usually change quickly - 60s seems reasonable if openHAB is controlling it and your don't want near instant feedback of physical interactions with the devices.
+
+### AirPurifier configuration parameters
+
+It is recommended to use the device name, for locating devices. For this to work all the devices should have a unique
+name in the VeSync mobile application.
+
+The mac address from the VeSync mobile application may not align to the one the API
+uses, therefore it's best left not configured or taken from auto-discovered information.
+
+Device's will be found communicated with via the MAC Id first and if unsuccessful then by the deviceName.
+
+| Name                   | Type                    | Description                                                         |
+|------------------------|-------------------------|---------------------------------------------------------------------|
+| deviceName             | String                  | The name given to the device under Settings -> Device Name          |
+| macId                  | String                  | The mac for the device under Settings -> Device Info -> MAC Address |
+
+
+## Channels
+
+Channel names in **bold** are read/write, everything else is read-only
+
+### AirPurifier Thing
+
+| Channel              | Type                 | Description                                                | Model's Supported | Controllable Values   |
+|----------------------|----------------------|------------------------------------------------------------|-------------------|-----------------------|
+| **enabled**          | Switch               | Whether the hardware device is enabled (Switched on)       | 600S, 400S, 300S  | [ON, OFF]             |
+| **childLock**        | Switch               | Whether the child lock (display lock is enabled)           | 600S, 400S, 300S  | [ON, OFF]             |
+| **display**          | Switch               | Whether the display is enabled (display is shown)          | 600S, 400S, 300S  | [ON, OFF]             |
+| **fanMode**          | String               | The operation mode of the fan                              | 600S, 400S        | [auto, manual, sleep] |
+| **fanMode**          | String               | The operation mode of the fan                              | 200S, 300S,       | [manual, sleep]       |
+| **manualFanSpeed**   | Number:Dimensionless | The speed of the fan when in manual mode                   | 600S, 400S        | [1...4]               |
+| **manualFanSpeed**   | Number:Dimensionless | The speed of the fan when in manual mode                   | 300S              | [1...3]               |
+| **nightLightMode**   | String               | The night lights mode                                      | 200S, 300S        | [on, dim, off]        |
+| filterLifePercentage | Number:Dimensionless | The remaining filter life as a percentage                  | 600S, 400S, 300S  |                       |
+| airQuality           | Number:Dimensionless | The air quality as represented by the Core200S / Core300S  | 600S, 400S, 300S  |                       |
+| airQualityPM25       | Number:Density       | The air quality as represented by the Core400S             | 600S, 400S, 300S  |                       |
+| errorCode            | Number:Dimensionless | The error code reported by the device                      | 600S, 400S, 300S  |                       |
+| timerExpiry          | DateTime             | The expected expiry time of the current timer              | 600S, 400S        |                       |
+| schedulesCount       | Number:Dimensionless | The number schedules configured                            | 600S, 400S        |                       |
+| configDisplayForever | Switch               | Config: Whether the display will disable when not active   | 600S, 400S, 300S  |                       |
+| configAutoMode       | String               | Config: The mode of operation when auto is active          | 600S, 400S, 300S  |                       |
+| configAutoRoomSize   | Number:Dimensionless | Config: The room size set when auto utilises the room size | 600S, 400S, 300S  |                       |
+
+
+### AirHumidifier Thing
+
+| Channel                    | Type                 | Description                                                   | Model's Supported          | Controllable Values |
+|----------------------------|----------------------|---------------------------------------------------------------|----------------------------|---------------------|
+| **enabled**                | Switch               | Whether the hardware device is enabled (Switched on)          | 200S, Dual200S, 300S, 600S | [ON, OFF]           |
+| **display**                | Switch               | Whether the display is enabled (display is shown)             | 200S, Dual200S, 300S, 600S | [ON, OFF]           |
+| waterLacking               | Switch               | Indicator whether the unit is lacking water                   | 200S, Dual200S, 300S, 600S |                     |
+| humidityHigh               | Switch               | Indicator for high humidity                                   | 200S, Dual200S, 300S, 600S |                     |
+| waterTankLifted            | Switch               | Indicator for whether the water tank is removed               | 200S, Dual200S, 300S, 600S |                     |
+| **stopAtHumiditySetpoint** | Switch               | Whether the unit is set to stop when the set point is reached | 200S, Dual200S, 300S, 600S | [ON, OFF]           |
+| humidity                   | Number:Dimensionless | Indicator for the currently measured humidity % level         | 200S, Dual200S, 300S, 600S |                     |
+| **mistLevel**              | Number:Dimensionless | The current mist level set                                    | 300S                       | [1...2]             |
+| **mistLevel**              | Number:Dimensionless | The current mist level set                                    | 200S, Dual200S, 600S       | [1...3]             |
+| **humidifierMode**         | String               | The current mode of operation                                 | 200S, Dual200S, 300S, 600S | [auto, sleep]       |
+| **nightLightMode**         | String               | The night light mode                                          | 200S, Dual200S, 300S       | [on, dim, off]      |
+| **humiditySetpoint**       | Number:Dimensionless | Humidity % set point to reach                                 | 200S, Dual200S, 300S, 600S | [30...80]           |
+| warmEnabled                | Switch               | Indicator for warm mist mode                                  | 600S                       |                     |
+
+
+## Full Example
+
+### Configuration (*.things)
+
+#### Air Purifiers Core 200S/300S/400S Models & Air Humidifier Classic300S/600S Models
+
+```
+Bridge vesync:bridge:vesyncServers [username="<USERNAME>", password="<PASSWORD>", airPurifierPollInterval=60] {
+       airPurifier loungeAirFilter [deviceName="<DEVICE NAME FROM APP>"]
+       airPurifier bedroomAirFilter [deviceName="<DEVICE NAME FROM APP>"]
+       airHumidifier loungeHumidifier [deviceName="<DEVICE NAME FROM APP>"]
+}
+```
+
+### Configuration (*.items)
+
+#### Air Purifier Core 400S / 600S Model
+
+```
+Switch               LoungeAPPower                     "Lounge Air Purifier Power"                                 { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:enabled" }
+Switch               LoungeAPDisplay                   "Lounge Air Purifier Display"                               { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:display" }
+Switch               LoungeAPControlsLock          "Lounge Air Purifier Controls Locked"                        { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:childLock" }
+Number:Dimensionless LoungeAPFilterRemainingUse    "Lounge Air Purifier Filter Remaining [%.0f %unit%]"         { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:filterLifePercentage" }
+String               LoungeAPMode                  "Lounge Air Purifier Mode [%s]"                              { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:fanMode" }
+Number:Dimensionless LoungeAPManualFanSpeed        "Lounge Air Purifier Manual Fan Speed"                       { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:manualFanSpeed" }
+Number:Density       LoungeAPAirQuality                       "Lounge Air Purifier Air Quality [%.0f% %unit%]"             { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:airQualityPM25" }
+Number               LoungeAPErrorCode                "Lounge Air Purifier Error Code"                             { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:errorCode" }
+String               LoungeAPAutoMode                 "Lounge Air Purifier Auto Mode"                              { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:configAutoMode" }
+Number               LoungeAPAutoRoomSize             "Lounge Air Purifier Auto Room Size [%.0f% sqft]"            { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:configAutoRoomSize" }
+Number:Time          LoungeAPTimerLeft                "Lounge Air Purifier Timer Left [%1$Tp]"                     { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:timerRemain" }  
+DateTime             LoungeAPTimerExpiry           "Lounge Air Purifier Timer Expiry [%1$tA %1$tI:%1$tM %1$Tp]" { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:timerExpiry" }
+Number               LoungeAPSchedulesCount       "Lounge Air Purifier Schedules Count"                        { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:schedulesCount" }
+```
+
+#### Air Purifier Core 200S/300S Model
+
+```
+Switch               LoungeAPPower                    "Lounge Air Purifier Power"                                  { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:enabled" }
+Switch               LoungeAPDisplay                  "Lounge Air Purifier Display"                                { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:display" }
+String               LoungeAPNightLightMode        "Lounge Air Purifier Night Light Mode"                       { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:nightLightMode" }
+Switch               LoungeAPControlsLock          "Lounge Air Purifier Controls Locked"                        { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:childLock" }
+Number:Dimensionless LoungeAPFilterRemainingUse    "Lounge Air Purifier Filter Remaining [%.0f %unit%]"         { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:filterLifePercentage" }
+String               LoungeAPMode                  "Lounge Air Purifier Mode [%s]"                              { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:fanMode" }
+Number:Dimensionless LoungeAPManualFanSpeed        "Lounge Air Purifier Manual Fan Speed"                       { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:manualFanSpeed" }
+Number:Density       LoungeAPAirQuality                       "Lounge Air Purifier Air Quality [%.0f%]"                    { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:airQuality" }
+Number               LoungeAPErrorCode                "Lounge Air Purifier Error Code"                             { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:errorCode" }
+String               LoungeAPAutoMode                 "Lounge Air Purifier Auto Mode"                              { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:configAutoMode" }
+Number               LoungeAPAutoRoomSize             "Lounge Air Purifier Auto Room Size [%.0f% sqft]"            { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:configAutoRoomSize" }
+Number:Time          LoungeAPTimerLeft                "Lounge Air Purifier Timer Left [%1$Tp]"                     { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:timerRemain" }  
+DateTime             LoungeAPTimerExpiry           "Lounge Air Purifier Timer Expiry [%1$tA %1$tI:%1$tM %1$Tp]" { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:timerExpiry" }
+Number               LoungeAPSchedulesCount       "Lounge Air Purifier Schedules Count"                        { channel="vesync:airPurifier:vesyncServers:loungeAirFilter:schedulesCount" }
+```
+
+#### Air Humidifier Classic 200S / Dual 200S Model
+
+```
+Switch               LoungeAHPower             "Lounge Air Humidifier Power"                                  { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:enabled" }
+Switch               LoungeAHDisplay           "Lounge Air Humidifier Display"                                { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:display" }
+String               LoungeAHMode              "Lounge Air Humidifier Mode"                                   { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:humidifierMode" }
+Switch               LoungeAHWaterLacking      "Lounge Air Humidifier Water Lacking"                          { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:waterLacking" }
+Switch               LoungeAHHighHumidity      "Lounge Air Humidifier High Humidity"                          { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:humidityHigh" }
+Switch               LoungeAHWaterTankRemoved  "Lounge Air Humidifier Water Tank Removed"                     { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:waterTankLifted" }
+Number:Dimensionless LoungeAHHumidity          "Lounge Air Humidifier Measured Humidity [%.0f %unit%]"        { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:humidity" }
+Switch               LoungeAHTargetStop        "Lounge Air Humidifier Stop at target"                         { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:stopAtTargetLevel" }
+Number:Dimensionless LoungeAHTarget            "Lounge Air Humidifier Target Humidity [%.0f %unit%]"          { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:humiditySetpoint" }
+Number:Dimensionless LoungeAHMistLevel         "Lounge Air Humidifier Mist Level"                             { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:mistLevel" }
+```
+
+#### Air Humidifier Classic 300S Model
+
+```
+Switch               LoungeAHPower             "Lounge Air Humidifier Power"                                  { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:enabled" }
+Switch               LoungeAHDisplay           "Lounge Air Humidifier Display"                                { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:display" }
+String               LoungeAHNightLightMode    "Lounge Air Humidifier Night Light Mode"                       { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:nightLightMode }
+String               LoungeAHMode              "Lounge Air Humidifier Mode"                                   { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:humidifierMode" }
+Switch               LoungeAHWaterLacking      "Lounge Air Humidifier Water Lacking"                          { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:waterLacking" }
+Switch               LoungeAHHighHumidity      "Lounge Air Humidifier High Humidity"                          { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:humidityHigh" }
+Switch               LoungeAHWaterTankRemoved  "Lounge Air Humidifier Water Tank Removed"                     { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:waterTankLifted" }
+Number:Dimensionless LoungeAHHumidity          "Lounge Air Humidifier Measured Humidity [%.0f %unit%]"        { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:humidity" }
+Switch               LoungeAHTargetStop        "Lounge Air Humidifier Stop at target"                         { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:stopAtTargetLevel" }
+Number:Dimensionless LoungeAHTarget            "Lounge Air Humidifier Target Humidity [%.0f %unit%]"          { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:humiditySetpoint" }
+Number:Dimensionless LoungeAHMistLevel         "Lounge Air Humidifier Mist Level"                             { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:mistLevel" }
+```
+
+#### Air Humidifier 600S Model
+
+```
+Switch               LoungeAHPower             "Lounge Air Humidifier Power"                                  { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:enabled" }
+Switch               LoungeAHDisplay           "Lounge Air Humidifier Display"                                { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:display" }
+String               LoungeAHMode              "Lounge Air Humidifier Mode"                                   { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:humidifierMode" }
+Switch               LoungeAHWaterLacking      "Lounge Air Humidifier Water Lacking"                          { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:waterLacking" }
+Switch               LoungeAHHighHumidity      "Lounge Air Humidifier High Humidity"                          { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:humidityHigh" }
+Switch               LoungeAHWaterTankRemoved  "Lounge Air Humidifier Water Tank Removed"                     { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:waterTankLifted" }
+Number:Dimensionless LoungeAHHumidity          "Lounge Air Humidifier Measured Humidity [%.0f %unit%]"        { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:humidity" }
+Switch               LoungeAHTargetStop        "Lounge Air Humidifier Stop at target"                         { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:stopAtTargetLevel" }
+Number:Dimensionless LoungeAHTarget            "Lounge Air Humidifier Target Humidity [%.0f %unit%]"          { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:humiditySetpoint" }
+Number:Dimensionless LoungeAHMistLevel         "Lounge Air Humidifier Mist Level"                             { channel="vesync:airHumidifier:vesyncServers:loungeHumidifier:mistLevel" }
+```
+
+### Configuration (*.sitemap)
+
+#### Air Purifier Core 400S / 600S Model
+
+```
+Frame {
+   Switch item=LoungeAPPower label="Power"
+   Text   item=LoungeAPFilterRemainingUse label="Filter Remaining"
+   Switch item=LoungeAPDisplay label="Display"
+   Text   item=LoungeAPAirQuality label="Air Quality [%.0f (PM2.5)]"                
+   Switch item=LoungeAPControlsLock label="Controls Locked"
+   Text   item=LoungeAPTimerExpiry label="Timer Shutdown @" icon="clock"
+   Switch item=LoungeAPMode label="Mode" mappings=[auto="Auto", manual="Manual Fan Control", sleep="Sleeping"] icon="settings"
+   Text   item=LoungeAPErrorCode label="Error Code [%.0f]"
+   Switch item=LoungeAPManualFanSpeed label="Manual Fan Speed [%.0f]" mappings=[1="1", 2="2", 3="3", 4="4"] icon="settings"                               
+}
+```
+
+#### Air Purifier Core 200S/300S Model
+
+```
+Frame {
+   Switch item=LoungeAPPower label="Power"
+   Text   item=LoungeAPFilterRemainingUse label="Filter Remaining"
+   Switch item=LoungeAPDisplay label="Display"
+   Switch item=LoungeAPNightLightMode label="Night Light Mode" mappings=[on="On", dim="Dimmed", off="Off"] icon="settings"
+   Text   item=LoungeAPAirQuality label="Air Quality [%.0f]"                
+   Switch item=LoungeAPControlsLock label="Controls Locked"
+   Text   item=LoungeAPTimerExpiry label="Timer Shutdown @" icon="clock"
+   Switch item=LoungeAPMode label="Mode" mappings=[manual="Manual Fan Control", sleep="Sleeping"] icon="settings"
+   Text   item=LoungeAPErrorCode label="Error Code [%.0f]"
+   Switch item=LoungeAPManualFanSpeed label="Manual Fan Speed [%.0f]" mappings=[1="1", 2="2", 3="3"] icon="settings"                               
+}
+```
+
+#### Air Humidifier Classic 200S / Dual 200S Model
+
+```
+Frame {
+   Switch item=LoungeAHPower
+   Switch item=LoungeAHDisplay
+   Switch item=LoungeAHMode label="Mode" mappings=[auto="Auto", sleep="Sleeping"] icon="settings"
+   Text   icon="none" item=LoungeAHWaterLacking
+   Text   icon="none" item=LoungeAHHighHumidity
+   Text   icon="none" item=LoungeAHWaterTankRemoved
+   Text   icon="none" item=LoungeAHHumidity
+   Switch item=LoungeAHTargetStop
+   Slider item=LoungeAHTarget minValue=30 maxValue=80
+   Slider item=LoungeAHMistLevel minValue=1 maxValue=3
+}
+```
+
+#### Air Humidifier Classic 300S Model
+
+```
+Frame {
+   Switch item=LoungeAHPower
+   Switch item=LoungeAHDisplay
+   Switch item=LoungeAHNightLightMode label="Night Light Mode" mappings=[on="On", dim="Dimmed", off="Off"] icon="settings"
+   Switch item=LoungeAHMode label="Mode" mappings=[auto="Auto", sleep="Sleeping"] icon="settings"
+   Text   icon="none" item=LoungeAHWaterLacking
+   Text   icon="none" item=LoungeAHHighHumidity
+   Text   icon="none" item=LoungeAHWaterTankRemoved
+   Text   icon="none" item=LoungeAHHumidity
+   Switch item=LoungeAHTargetStop
+   Slider item=LoungeAHTarget minValue=30 maxValue=80
+   Slider item=LoungeAHMistLevel minValue=1 maxValue=3
+}
+```
+
+#### Air Humidifier 600S Model
+
+```
+Frame {
+   Switch item=LoungeAHPower
+   Switch item=LoungeAHDisplay
+   Switch item=LoungeAHMode label="Mode" mappings=[auto="Auto", sleep="Sleeping"] icon="settings"
+   Text   icon="none" item=LoungeAHWaterLacking
+   Text   icon="none" item=LoungeAHHighHumidity
+   Text   icon="none" item=LoungeAHWaterTankRemoved
+   Text   icon="none" item=LoungeAHHumidity
+   Switch item=LoungeAHTargetStop
+   Slider item=LoungeAHTarget minValue=30 maxValue=80
+   Slider item=LoungeAHMistLevel minValue=1 maxValue=3
+}
+```
+
+### Credits
+
+The binding code is based on a lot of work done by other developers:
+
+- Contributors of (https://github.com/webdjoe/pyvesync) - Python interface for VeSync
+- Rene Scherer, Holger Eisold - (https://www.openhab.org/addons/bindings/surepetcare) Sure Petcare Binding for openHAB as a reference point for the starting blocks of this code
diff --git a/bundles/org.openhab.binding.vesync/pom.xml b/bundles/org.openhab.binding.vesync/pom.xml
new file mode 100644 (file)
index 0000000..2e3e1b7
--- /dev/null
@@ -0,0 +1,17 @@
+<?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 http://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>3.3.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>org.openhab.binding.vesync</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: VeSync Binding</name>
+
+</project>
diff --git a/bundles/org.openhab.binding.vesync/src/main/feature/feature.xml b/bundles/org.openhab.binding.vesync/src/main/feature/feature.xml
new file mode 100644 (file)
index 0000000..3d64763
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.vesync-${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-vesync" description="VeSync Binding" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.vesync/${project.version}</bundle>
+       </feature>
+</features>
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/VeSyncBridgeConfiguration.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/VeSyncBridgeConfiguration.java
new file mode 100644 (file)
index 0000000..4cd3edb
--- /dev/null
@@ -0,0 +1,43 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link VeSyncBridgeConfiguration} class contains fields mapping the configuration parameters for the bridge.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class VeSyncBridgeConfiguration {
+
+    /**
+     * The clear text password to access the vesync API.
+     */
+    @Nullable
+    public String password = "";
+
+    /**
+     * The email address / username to access the vesync API.
+     */
+    @Nullable
+    public String username = "";
+
+    /**
+     * The polling interval to use for air purifier devices.
+     */
+    @Nullable
+    public Integer airPurifierPollInterval;
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/VeSyncConstants.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/VeSyncConstants.java
new file mode 100644 (file)
index 0000000..137bc9a
--- /dev/null
@@ -0,0 +1,96 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.ThingTypeUID;
+
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
+/**
+ * The {@link VeSyncConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class VeSyncConstants {
+
+    public static final Gson GSON = new GsonBuilder()
+            .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).setPrettyPrinting()
+            .disableHtmlEscaping().serializeNulls().create();
+
+    private static final String BINDING_ID = "vesync";
+
+    public static final long DEFAULT_REFRESH_INTERVAL_DISCOVERED_DEVICES = 3600;
+    public static final long DEFAULT_POLL_INTERVAL_AIR_FILTERS_DEVICES = 10;
+
+    // List of all Thing Type UIDs
+    public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "bridge");
+    public static final ThingTypeUID THING_TYPE_AIR_PURIFIER = new ThingTypeUID(BINDING_ID, "airPurifier");
+    public static final ThingTypeUID THING_TYPE_AIR_HUMIDIFIER = new ThingTypeUID(BINDING_ID, "airHumidifier");
+
+    // Thing configuration properties
+    public static final String DEVICE_MAC_ID = "macAddress";
+
+    public static final String EMPTY_STRING = "";
+
+    // Base Device Channel Names
+    public static final String DEVICE_CHANNEL_ENABLED = "enabled";
+    public static final String DEVICE_CHANNEL_DISPLAY_ENABLED = "display";
+    public static final String DEVICE_CHANNEL_CHILD_LOCK_ENABLED = "childLock";
+    public static final String DEVICE_CHANNEL_AIR_FILTER_LIFE_PERCENTAGE_REMAINING = "filterLifePercentage";
+    public static final String DEVICE_CHANNEL_FAN_MODE_ENABLED = "fanMode";
+    public static final String DEVICE_CHANNEL_FAN_SPEED_ENABLED = "manualFanSpeed";
+    public static final String DEVICE_CHANNEL_ERROR_CODE = "errorCode";
+    public static final String DEVICE_CHANNEL_AIRQUALITY_BASIC = "airQuality";
+    public static final String DEVICE_CHANNEL_AIRQUALITY_PM25 = "airQualityPM25";
+
+    public static final String DEVICE_CHANNEL_AF_CONFIG_DISPLAY_FOREVER = "configDisplayForever";
+    public static final String DEVICE_CHANNEL_AF_CONFIG_AUTO_MODE_PREF = "configAutoMode";
+
+    public static final String DEVICE_CHANNEL_AF_AUTO_OFF_CALC_TIME = "timerExpiry";
+    public static final String DEVICE_CHANNEL_AF_CONFIG_AUTO_ROOM_SIZE = "configAutoRoomSize";
+    public static final String DEVICE_CHANNEL_AF_SCHEDULES_COUNT = "schedulesCount";
+    public static final String DEVICE_CHANNEL_AF_NIGHT_LIGHT = "nightLightMode";
+
+    // Humidity related channels
+    public static final String DEVICE_CHANNEL_WATER_LACKS = "waterLacking";
+    public static final String DEVICE_CHANNEL_HUMIDITY_HIGH = "humidityHigh";
+    public static final String DEVICE_CHANNEL_WATER_TANK_LIFTED = "waterTankLifted";
+    public static final String DEVICE_CHANNEL_STOP_AT_TARGET = "stopAtHumiditySetpoint";
+    public static final String DEVICE_CHANNEL_HUMIDITY = "humidity";
+    public static final String DEVICE_CHANNEL_MIST_LEVEL = "mistLevel";
+    public static final String DEVICE_CHANNEL_HUMIDIFIER_MODE = "humidifierMode";
+    public static final String DEVICE_CHANNEL_WARM_ENABLED = "warmEnabled";
+    public static final String DEVICE_CHANNEL_WARM_LEVEL = "warmLevel";
+
+    public static final String DEVICE_CHANNEL_CONFIG_TARGET_HUMIDITY = "humiditySetpoint";
+
+    // Property name constants
+    public static final String DEVICE_PROP_DEVICE_NAME = "Device Name";
+    public static final String DEVICE_PROP_DEVICE_TYPE = "Device Type";
+    public static final String DEVICE_PROP_DEVICE_MAC_ID = "MAC Id";
+    public static final String DEVICE_PROP_DEVICE_UUID = "UUID";
+
+    // Property name for config constants
+    public static final String DEVICE_PROP_CONFIG_DEVICE_NAME = "deviceName";
+    public static final String DEVICE_PROP_CONFIG_DEVICE_MAC = "macId";
+
+    // Bridge name constants
+    public static final String DEVICE_PROP_BRIDGE_REG_TS = "Registration Time";
+    public static final String DEVICE_PROP_BRIDGE_COUNTRY_CODE = "Country Code";
+    public static final String DEVICE_PROP_BRIDGE_ACCEPT_LANG = "Accept Language";
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/VeSyncDeviceConfiguration.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/VeSyncDeviceConfiguration.java
new file mode 100644 (file)
index 0000000..be355f4
--- /dev/null
@@ -0,0 +1,38 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link VeSyncDeviceConfiguration} class contains fields mapping the configuration parameters for a VeSync
+ * device's configuration.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class VeSyncDeviceConfiguration {
+
+    /**
+     * The clear text device name as reported by the API.
+     */
+    @Nullable
+    public String deviceName;
+
+    /**
+     * The mac address of the device as reported by the API.
+     */
+    @Nullable
+    public String macId;
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/VeSyncHandlerFactory.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/VeSyncHandlerFactory.java
new file mode 100644 (file)
index 0000000..fa66096
--- /dev/null
@@ -0,0 +1,80 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal;
+
+import static org.openhab.binding.vesync.internal.VeSyncConstants.*;
+
+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.vesync.internal.api.IHttpClientProvider;
+import org.openhab.binding.vesync.internal.handlers.VeSyncBridgeHandler;
+import org.openhab.binding.vesync.internal.handlers.VeSyncDeviceAirHumidifierHandler;
+import org.openhab.binding.vesync.internal.handlers.VeSyncDeviceAirPurifierHandler;
+import org.openhab.core.io.net.http.HttpClientFactory;
+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.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * The {@link org.openhab.binding.vesync.internal.VeSyncHandlerFactory} is responsible for creating
+ * things and thing handlers.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.vesync", service = ThingHandlerFactory.class)
+public class VeSyncHandlerFactory extends BaseThingHandlerFactory implements IHttpClientProvider {
+
+    private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE,
+            THING_TYPE_AIR_PURIFIER, THING_TYPE_AIR_HUMIDIFIER);
+
+    private @Nullable HttpClient httpClientRef = null;
+
+    @Override
+    public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+        return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+    }
+
+    @Override
+    protected @Nullable ThingHandler createHandler(Thing thing) {
+        final ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+        if (VeSyncDeviceAirPurifierHandler.SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID)) {
+            return new VeSyncDeviceAirPurifierHandler(thing);
+        } else if (VeSyncDeviceAirHumidifierHandler.SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID)) {
+            return new VeSyncDeviceAirHumidifierHandler(thing);
+        } else if (THING_TYPE_BRIDGE.equals(thingTypeUID)) {
+            return new VeSyncBridgeHandler((Bridge) thing, this);
+        }
+
+        return null;
+    }
+
+    @Reference
+    protected void setHttpClientFactory(HttpClientFactory httpClientFactory) {
+        httpClientRef = httpClientFactory.getCommonHttpClient();
+    }
+
+    @Override
+    public @Nullable HttpClient getHttpClient() {
+        return httpClientRef;
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/api/IHttpClientProvider.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/api/IHttpClientProvider.java
new file mode 100644 (file)
index 0000000..c216004
--- /dev/null
@@ -0,0 +1,26 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.api;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+
+/**
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public interface IHttpClientProvider {
+    @Nullable
+    HttpClient getHttpClient();
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/api/VeSyncV2ApiHelper.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/api/VeSyncV2ApiHelper.java
new file mode 100644 (file)
index 0000000..1782a63
--- /dev/null
@@ -0,0 +1,254 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.api;
+
+import static org.openhab.binding.vesync.internal.dto.requests.VeSyncProtocolConstants.*;
+
+import java.net.HttpURLConnection;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import javax.validation.constraints.NotNull;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.client.api.Request;
+import org.eclipse.jetty.client.util.StringContentProvider;
+import org.eclipse.jetty.http.HttpHeader;
+import org.openhab.binding.vesync.internal.VeSyncConstants;
+import org.openhab.binding.vesync.internal.dto.requests.VeSyncAuthenticatedRequest;
+import org.openhab.binding.vesync.internal.dto.requests.VeSyncLoginCredentials;
+import org.openhab.binding.vesync.internal.dto.requests.VeSyncRequestManagedDeviceBypassV2;
+import org.openhab.binding.vesync.internal.dto.requests.VeSyncRequestManagedDevicesPage;
+import org.openhab.binding.vesync.internal.dto.responses.VeSyncLoginResponse;
+import org.openhab.binding.vesync.internal.dto.responses.VeSyncManagedDeviceBase;
+import org.openhab.binding.vesync.internal.dto.responses.VeSyncManagedDevicesPage;
+import org.openhab.binding.vesync.internal.dto.responses.VeSyncResponse;
+import org.openhab.binding.vesync.internal.dto.responses.VeSyncUserSession;
+import org.openhab.binding.vesync.internal.exceptions.AuthenticationException;
+import org.openhab.binding.vesync.internal.exceptions.DeviceUnknownException;
+import org.openhab.binding.vesync.internal.handlers.VeSyncBridgeHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class VeSyncV2ApiHelper {
+
+    private final Logger logger = LoggerFactory.getLogger(VeSyncV2ApiHelper.class);
+
+    private @NonNullByDefault({}) HttpClient httpClient;
+
+    private volatile @Nullable VeSyncUserSession loggedInSession;
+
+    private Map<String, @NotNull VeSyncManagedDeviceBase> macLookup;
+
+    public VeSyncV2ApiHelper() {
+        macLookup = new HashMap<>();
+    }
+
+    public Map<String, @NotNull VeSyncManagedDeviceBase> getMacLookupMap() {
+        return macLookup;
+    }
+
+    /**
+     * Sets the httpClient object to be used for API calls to Vesync.
+     *
+     * @param httpClient the client to be used.
+     */
+    public void setHttpClient(@Nullable HttpClient httpClient) {
+        this.httpClient = httpClient;
+    }
+
+    public static @NotNull String calculateMd5(final @Nullable String password) {
+        if (password == null) {
+            return "";
+        }
+        MessageDigest md5;
+        StringBuilder md5Result = new StringBuilder();
+        try {
+            md5 = MessageDigest.getInstance("MD5");
+        } catch (NoSuchAlgorithmException e) {
+            return "";
+        }
+        byte[] handshakeHash = md5.digest(password.getBytes(StandardCharsets.UTF_8));
+        for (byte handshakeByte : handshakeHash) {
+            md5Result.append(String.format("%02x", handshakeByte));
+        }
+        return md5Result.toString();
+    }
+
+    public void discoverDevices() throws AuthenticationException {
+        try {
+            VeSyncRequestManagedDevicesPage reqDevPage = new VeSyncRequestManagedDevicesPage(loggedInSession);
+            boolean finished = false;
+            int pageNo = 1;
+            HashMap<String, VeSyncManagedDeviceBase> generatedMacLookup = new HashMap<>();
+            while (!finished) {
+                reqDevPage.pageNo = String.valueOf(pageNo);
+                reqDevPage.pageSize = String.valueOf(100);
+                final String result = reqV1Authorized(V1_MANAGED_DEVICES_ENDPOINT, reqDevPage);
+
+                VeSyncManagedDevicesPage resultsPage = VeSyncConstants.GSON.fromJson(result,
+                        VeSyncManagedDevicesPage.class);
+                if (resultsPage == null || !resultsPage.outcome.getTotal().equals(resultsPage.outcome.getPageSize())) {
+                    finished = true;
+                } else {
+                    ++pageNo;
+                }
+
+                if (resultsPage != null) {
+                    for (VeSyncManagedDeviceBase device : resultsPage.outcome.list) {
+                        logger.debug(
+                                "Found device : {}, type: {}, deviceType: {}, connectionState: {}, deviceStatus: {}, deviceRegion: {}, cid: {}, configModule: {}, macID: {}, uuid: {}",
+                                device.getDeviceName(), device.getType(), device.getDeviceType(),
+                                device.getConnectionStatus(), device.getDeviceStatus(), device.getDeviceRegion(),
+                                device.getCid(), device.getConfigModule(), device.getMacId(), device.getUuid());
+
+                        // Update the mac address -> device table
+                        generatedMacLookup.put(device.getMacId(), device);
+                    }
+                }
+            }
+            macLookup = Collections.unmodifiableMap(generatedMacLookup);
+        } catch (final AuthenticationException ae) {
+            logger.warn("Failed background device scan : {}", ae.getMessage());
+            throw ae;
+        }
+    }
+
+    public String reqV2Authorized(final String url, final String macId, final VeSyncAuthenticatedRequest requestData)
+            throws AuthenticationException, DeviceUnknownException {
+        if (loggedInSession == null) {
+            throw new AuthenticationException("User is not logged in");
+        }
+        // Apply current session authentication data
+        requestData.applyAuthentication(loggedInSession);
+
+        // Apply specific addressing parameters
+        if (requestData instanceof VeSyncRequestManagedDeviceBypassV2) {
+            final VeSyncManagedDeviceBase deviceData = macLookup.get(macId);
+            if (deviceData == null) {
+                throw new DeviceUnknownException(String.format("Device not discovered with mac id: %s", macId));
+            }
+            ((VeSyncRequestManagedDeviceBypassV2) requestData).cid = deviceData.cid;
+            ((VeSyncRequestManagedDeviceBypassV2) requestData).configModule = deviceData.configModule;
+            ((VeSyncRequestManagedDeviceBypassV2) requestData).deviceRegion = deviceData.deviceRegion;
+        }
+        return reqV1Authorized(url, requestData);
+    }
+
+    public String reqV1Authorized(final String url, final VeSyncAuthenticatedRequest requestData)
+            throws AuthenticationException {
+        try {
+            return directReqV1Authorized(url, requestData);
+        } catch (final AuthenticationException ae) {
+            throw ae;
+        }
+    }
+
+    private String directReqV1Authorized(final String url, final VeSyncAuthenticatedRequest requestData)
+            throws AuthenticationException {
+        try {
+            Request request = httpClient.POST(url);
+
+            // No headers for login
+            request.content(new StringContentProvider(VeSyncConstants.GSON.toJson(requestData)));
+
+            logger.debug("POST @ {} with content\r\n{}", url, VeSyncConstants.GSON.toJson(requestData));
+
+            request.header(HttpHeader.CONTENT_TYPE, "application/json; utf-8");
+
+            ContentResponse response = request.timeout(5, TimeUnit.SECONDS).send();
+            if (response.getStatus() == HttpURLConnection.HTTP_OK) {
+                VeSyncResponse commResponse = VeSyncConstants.GSON.fromJson(response.getContentAsString(),
+                        VeSyncResponse.class);
+
+                if (commResponse != null && (commResponse.isMsgSuccess() || commResponse.isMsgDeviceOffline())) {
+                    logger.debug("Got OK response {}", response.getContentAsString());
+                    return response.getContentAsString();
+                } else {
+                    logger.debug("Got FAILED response {}", response.getContentAsString());
+                    throw new AuthenticationException("Invalid JSON response from login");
+                }
+            } else {
+                logger.debug("HTTP Response Code: {}", response.getStatus());
+                logger.debug("HTTP Response Msg: {}", response.getReason());
+                throw new AuthenticationException(
+                        "HTTP response " + response.getStatus() + " - " + response.getReason());
+            }
+        } catch (InterruptedException | TimeoutException | ExecutionException e) {
+            throw new AuthenticationException(e);
+        }
+    }
+
+    public synchronized void login(final @Nullable String username, final @Nullable String password,
+            final @Nullable String timezone) throws AuthenticationException {
+        if (username == null || password == null || timezone == null) {
+            loggedInSession = null;
+            return;
+        }
+        try {
+            loggedInSession = processLogin(username, password, timezone).getUserSession();
+        } catch (final AuthenticationException ae) {
+            loggedInSession = null;
+            throw ae;
+        }
+    }
+
+    public void updateBridgeData(final VeSyncBridgeHandler bridge) {
+        bridge.handleNewUserSession(loggedInSession);
+    }
+
+    private VeSyncLoginResponse processLogin(String username, String password, String timezone)
+            throws AuthenticationException {
+        try {
+            Request request = httpClient.POST(V1_LOGIN_ENDPOINT);
+
+            // No headers for login
+            request.content(new StringContentProvider(
+                    VeSyncConstants.GSON.toJson(new VeSyncLoginCredentials(username, password))));
+
+            request.header(HttpHeader.CONTENT_TYPE, "application/json; utf-8");
+
+            ContentResponse response = request.timeout(5, TimeUnit.SECONDS).send();
+            if (response.getStatus() == HttpURLConnection.HTTP_OK) {
+                VeSyncLoginResponse loginResponse = VeSyncConstants.GSON.fromJson(response.getContentAsString(),
+                        VeSyncLoginResponse.class);
+                if (loginResponse != null && loginResponse.isMsgSuccess()) {
+                    logger.debug("Login successful");
+                    return loginResponse;
+                } else {
+                    throw new AuthenticationException("Invalid / unexpected JSON response from login");
+                }
+            } else {
+                logger.warn("Login Failed - HTTP Response Code: {} - {}", response.getStatus(), response.getReason());
+                throw new AuthenticationException(
+                        "HTTP response " + response.getStatus() + " - " + response.getReason());
+            }
+        } catch (InterruptedException | TimeoutException | ExecutionException e) {
+            throw new AuthenticationException(e);
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/discovery/DeviceMetaDataUpdatedHandler.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/discovery/DeviceMetaDataUpdatedHandler.java
new file mode 100644 (file)
index 0000000..977ca3c
--- /dev/null
@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.discovery;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.vesync.internal.handlers.VeSyncBridgeHandler;
+
+/**
+ * The {@link DeviceMetaDataUpdatedHandler} enables call-backs for when the device meta-data is updated from a bridge.
+ * (VeSync Server Account).
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public interface DeviceMetaDataUpdatedHandler {
+    void handleMetadataRetrieved(VeSyncBridgeHandler handler);
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/discovery/VeSyncDiscoveryService.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/discovery/VeSyncDiscoveryService.java
new file mode 100644 (file)
index 0000000..a601a14
--- /dev/null
@@ -0,0 +1,143 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.discovery;
+
+import static org.openhab.binding.vesync.internal.VeSyncConstants.*;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.vesync.internal.handlers.VeSyncBridgeHandler;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.osgi.service.component.annotations.Component;
+
+/**
+ * The {@link VeSyncDiscoveryService} 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 = DiscoveryService.class, immediate = true, configurationPid = "discovery.vesync")
+public class VeSyncDiscoveryService extends AbstractDiscoveryService
+        implements DiscoveryService, ThingHandlerService, DeviceMetaDataUpdatedHandler {
+
+    private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_BRIDGE);
+
+    private static final int DISCOVER_TIMEOUT_SECONDS = 5;
+
+    private @NonNullByDefault({}) VeSyncBridgeHandler bridgeHandler;
+    private @NonNullByDefault({}) ThingUID bridgeUID;
+
+    /**
+     * Creates a VeSyncDiscoveryService with enabled autostart.
+     */
+    public VeSyncDiscoveryService() {
+        super(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 deactivate() {
+        super.deactivate();
+    }
+
+    @Override
+    public void setThingHandler(@Nullable ThingHandler handler) {
+        if (handler instanceof VeSyncBridgeHandler) {
+            bridgeHandler = (VeSyncBridgeHandler) handler;
+            bridgeUID = bridgeHandler.getUID();
+        }
+    }
+
+    @Override
+    public @Nullable ThingHandler getThingHandler() {
+        return bridgeHandler;
+    }
+
+    @Override
+    protected void startBackgroundDiscovery() {
+        if (bridgeHandler != null) {
+            bridgeHandler.registerMetaDataUpdatedHandler(this);
+        }
+    }
+
+    @Override
+    protected void stopBackgroundDiscovery() {
+        if (bridgeHandler != null) {
+            bridgeHandler.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());
+        if (ThingStatus.ONLINE.equals(bridgeHandler.getThing().getStatus())) {
+            bridgeHandler.runDeviceScanSequenceNoAuthErrors();
+        }
+    }
+
+    @Override
+    public void handleMetadataRetrieved(VeSyncBridgeHandler handler) {
+        bridgeHandler.getAirPurifiersMetadata().map(apMeta -> {
+            final Map<String, Object> properties = new HashMap<>(6);
+            final String deviceUUID = apMeta.getUuid();
+            properties.put(DEVICE_PROP_DEVICE_NAME, apMeta.getDeviceName());
+            properties.put(DEVICE_PROP_DEVICE_TYPE, apMeta.getDeviceType());
+            properties.put(DEVICE_PROP_DEVICE_MAC_ID, apMeta.getMacId());
+            properties.put(DEVICE_PROP_DEVICE_UUID, deviceUUID);
+            properties.put(DEVICE_PROP_CONFIG_DEVICE_MAC, apMeta.getMacId());
+            properties.put(DEVICE_PROP_CONFIG_DEVICE_NAME, apMeta.getDeviceName());
+            return DiscoveryResultBuilder.create(new ThingUID(THING_TYPE_AIR_PURIFIER, bridgeUID, deviceUUID))
+                    .withLabel(apMeta.getDeviceName()).withBridge(bridgeUID).withProperties(properties)
+                    .withRepresentationProperty(DEVICE_PROP_DEVICE_MAC_ID).build();
+        }).forEach(this::thingDiscovered);
+
+        bridgeHandler.getAirHumidifiersMetadata().map(apMeta -> {
+            final Map<String, Object> properties = new HashMap<>(6);
+            final String deviceUUID = apMeta.getUuid();
+            properties.put(DEVICE_PROP_DEVICE_NAME, apMeta.getDeviceName());
+            properties.put(DEVICE_PROP_DEVICE_TYPE, apMeta.getDeviceType());
+            properties.put(DEVICE_PROP_DEVICE_MAC_ID, apMeta.getMacId());
+            properties.put(DEVICE_PROP_DEVICE_UUID, deviceUUID);
+            properties.put(DEVICE_PROP_CONFIG_DEVICE_MAC, apMeta.getMacId());
+            properties.put(DEVICE_PROP_CONFIG_DEVICE_NAME, apMeta.getDeviceName());
+            return DiscoveryResultBuilder.create(new ThingUID(THING_TYPE_AIR_HUMIDIFIER, bridgeUID, deviceUUID))
+                    .withLabel(apMeta.getDeviceName()).withBridge(bridgeUID).withProperties(properties)
+                    .withRepresentationProperty(DEVICE_PROP_DEVICE_MAC_ID).build();
+        }).forEach(this::thingDiscovered);
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/VeSyncBridgeConfiguration.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/VeSyncBridgeConfiguration.java
new file mode 100644 (file)
index 0000000..9cb73a8
--- /dev/null
@@ -0,0 +1,30 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.dto;
+
+import static org.openhab.binding.vesync.internal.VeSyncConstants.DEFAULT_POLL_INTERVAL_AIR_FILTERS_DEVICES;
+import static org.openhab.binding.vesync.internal.VeSyncConstants.DEFAULT_REFRESH_INTERVAL_DISCOVERED_DEVICES;
+
+/**
+ * The {@link VeSyncBridgeConfiguration} is a container for all the bridge configuration.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+public class VeSyncBridgeConfiguration {
+
+    public String username;
+    public String password;
+    public long airPurifierPollInterval = DEFAULT_POLL_INTERVAL_AIR_FILTERS_DEVICES;
+    public boolean backgroundDeviceDiscovery;
+    public long refreshBackgroundDeviceDiscovery = DEFAULT_REFRESH_INTERVAL_DISCOVERED_DEVICES;
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncAuthenticatedRequest.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncAuthenticatedRequest.java
new file mode 100644 (file)
index 0000000..45f0f3f
--- /dev/null
@@ -0,0 +1,53 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.dto.requests;
+
+import org.openhab.binding.vesync.internal.dto.responses.VeSyncUserSession;
+import org.openhab.binding.vesync.internal.exceptions.AuthenticationException;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link VeSyncAuthenticatedRequest} is a Java class used as a DTO to hold the Vesync's API's common request data.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+public class VeSyncAuthenticatedRequest extends VeSyncRequest {
+
+    @SerializedName("accountID")
+    public String accountId;
+
+    @SerializedName("token")
+    public String token;
+
+    public VeSyncAuthenticatedRequest() {
+        super();
+    }
+
+    public VeSyncAuthenticatedRequest(final VeSyncUserSession user) throws AuthenticationException {
+        super();
+        if (user == null) {
+            throw new AuthenticationException("User is not logged in");
+        }
+        this.token = user.getToken();
+        this.accountId = user.getAccountId();
+    }
+
+    public void applyAuthentication(final VeSyncUserSession userSession) throws AuthenticationException {
+        if (userSession == null) {
+            throw new AuthenticationException("User is not logged in");
+        }
+        this.accountId = userSession.getAccountId();
+        this.token = userSession.getToken();
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncLoginCredentials.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncLoginCredentials.java
new file mode 100644 (file)
index 0000000..21cb532
--- /dev/null
@@ -0,0 +1,45 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.dto.requests;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link VeSyncLoginCredentials} is the Java class as a DTO to hold login credentials for the Vesync
+ * API.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+public class VeSyncLoginCredentials extends VeSyncRequest {
+
+    @SerializedName("email")
+    public String email;
+    @SerializedName("password")
+    public String passwordMd5;
+    @SerializedName("userType")
+    public String userType;
+    @SerializedName("devToken")
+    public String devToken = "";
+
+    public VeSyncLoginCredentials() {
+        super();
+        userType = "1";
+        method = "login";
+    }
+
+    public VeSyncLoginCredentials(String email, String password) {
+        this();
+        this.email = email;
+        this.passwordMd5 = password;
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncProtocolConstants.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncProtocolConstants.java
new file mode 100644 (file)
index 0000000..02078cc
--- /dev/null
@@ -0,0 +1,61 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.dto.requests;
+
+/**
+ * The {@link VeSyncProtocolConstants} contains common Strings used by various elements of the protocol.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+public interface VeSyncProtocolConstants {
+
+    // Common Payloads
+    String MODE_AUTO = "auto";
+    String MODE_MANUAL = "manual";
+    String MODE_SLEEP = "sleep";
+
+    String MODE_ON = "on";
+    String MODE_DIM = "dim";
+    String MODE_OFF = "off";
+
+    // Common Commands
+    String DEVICE_SET_SWITCH = "setSwitch";
+    String DEVICE_SET_DISPLAY = "setDisplay";
+    String DEVICE_SET_LEVEL = "setLevel";
+
+    // Humidifier Commands
+    String DEVICE_SET_AUTOMATIC_STOP = "setAutomaticStop";
+    String DEVICE_SET_HUMIDITY_MODE = "setHumidityMode";
+    String DEVICE_SET_TARGET_HUMIDITY_MODE = "setTargetHumidity";
+    String DEVICE_SET_VIRTUAL_LEVEL = "setVirtualLevel";
+    String DEVICE_SET_NIGHT_LIGHT_BRIGHTNESS = "setNightLightBrightness";
+    String DEVICE_GET_HUMIDIFIER_STATUS = "getHumidifierStatus";
+
+    String DEVICE_LEVEL_TYPE_MIST = "mist";
+
+    // Air Purifier Commands
+    String DEVICE_SET_PURIFIER_MODE = "setPurifierMode";
+    String DEVICE_SET_CHILD_LOCK = "setChildLock";
+    String DEVICE_SET_NIGHT_LIGHT = "setNightLight";
+    String DEVICE_GET_PURIFIER_STATUS = "getPurifierStatus";
+    String DEVICE_LEVEL_TYPE_WIND = "wind";
+
+    /**
+     * Base URL for AUTHENTICATION REQUESTS
+     */
+    String PROTOCOL = "https";
+    String HOST_ENDPOINT = PROTOCOL + "://smartapi.vesync.com/cloud";
+    String V1_LOGIN_ENDPOINT = HOST_ENDPOINT + "/v1/user/login";
+    String V1_MANAGED_DEVICES_ENDPOINT = HOST_ENDPOINT + "/v1/deviceManaged/devices";
+    String V2_BYPASS_ENDPOINT = HOST_ENDPOINT + "/v2/deviceManaged/bypassV2";
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncRequest.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncRequest.java
new file mode 100644 (file)
index 0000000..3b693c8
--- /dev/null
@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.dto.requests;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link VeSyncRequest} is a Java class used as a DTO to hold the Vesync's API's common request data.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+public class VeSyncRequest {
+
+    @SerializedName("timeZone")
+    public String timeZone = "America/New_York";
+
+    @SerializedName("acceptLanguage")
+    public String acceptLanguage = "en";
+
+    @SerializedName("appVersion")
+    public String appVersion = "2.5.1";
+
+    @SerializedName("phoneBrand")
+    public String phoneBrand = "SM N9005";
+
+    @SerializedName("phoneOS")
+    public String phoneOS = "Android";
+
+    @SerializedName("traceId")
+    public String traceId = "";
+
+    @SerializedName("method")
+    public String method;
+
+    public VeSyncRequest() {
+        traceId = String.valueOf(System.currentTimeMillis());
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncRequestManagedDeviceBypassV2.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncRequestManagedDeviceBypassV2.java
new file mode 100644 (file)
index 0000000..569203e
--- /dev/null
@@ -0,0 +1,164 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.dto.requests;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link VeSyncRequestManagedDeviceBypassV2} is a Java class used as a DTO to hold the Vesync's API's common
+ * request data for V2 ByPass payloads.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+public class VeSyncRequestManagedDeviceBypassV2 extends VeSyncAuthenticatedRequest {
+
+    @SerializedName("deviceRegion")
+    public String deviceRegion = "";
+
+    @SerializedName("debugMode")
+    public boolean debugMode = false;
+
+    @SerializedName("cid")
+    public String cid = "";
+
+    @SerializedName("configModule")
+    public String configModule = "";
+
+    @SerializedName("payload")
+    public VesyncManagedDeviceBase payload = new VesyncManagedDeviceBase();
+
+    /**
+     * Contains basic information about the device.
+     */
+    public class VesyncManagedDeviceBase {
+
+        @SerializedName("method")
+        public String method;
+
+        @SerializedName("source")
+        public String source = "APP";
+
+        @SerializedName("data")
+        public EmptyPayload data = new EmptyPayload();
+    }
+
+    public static class EmptyPayload {
+    }
+
+    public static class SetSwitchPayload extends EmptyPayload {
+
+        public SetSwitchPayload(final boolean enabled, final int id) {
+            this.enabled = enabled;
+            this.id = id;
+        }
+
+        @SerializedName("enabled")
+        public boolean enabled = true;
+
+        @SerializedName("id")
+        public int id = -1;
+    }
+
+    public static class EnabledPayload extends EmptyPayload {
+
+        public EnabledPayload(final boolean enabled) {
+            this.enabled = enabled;
+        }
+
+        @SerializedName("enabled")
+        public boolean enabled = true;
+    }
+
+    public static class SetLevelPayload extends EmptyPayload {
+
+        public SetLevelPayload(final int id, final String type, final int level) {
+            this.id = id;
+            this.type = type;
+            this.level = level;
+        }
+
+        @SerializedName("id")
+        public int id = -1;
+
+        @SerializedName("level")
+        public int level = -1;
+
+        @SerializedName("type")
+        public String type = "";
+    }
+
+    public static class SetState extends EmptyPayload {
+
+        public SetState(final boolean state) {
+            this.state = state;
+        }
+
+        @SerializedName("state")
+        public boolean state = false;
+    }
+
+    public static class SetNightLight extends EmptyPayload {
+
+        public SetNightLight(final String state) {
+            this.nightLight = state;
+        }
+
+        @SerializedName("night_light")
+        public String nightLight = "";
+    }
+
+    public static class SetNightLightBrightness extends EmptyPayload {
+
+        public SetNightLightBrightness(final int state) {
+            this.nightLightLevel = state;
+        }
+
+        @SerializedName("night_light_brightness")
+        public int nightLightLevel = 0;
+    }
+
+    public static class SetTargetHumidity extends EmptyPayload {
+
+        public SetTargetHumidity(final int state) {
+            this.targetHumidity = state;
+        }
+
+        @SerializedName("target_humidity")
+        public int targetHumidity = 0;
+    }
+
+    public static class SetChildLock extends EmptyPayload {
+
+        public SetChildLock(final boolean childLock) {
+            this.childLock = childLock;
+        }
+
+        @SerializedName("child_lock")
+        public boolean childLock = false;
+    }
+
+    public static class SetMode extends EmptyPayload {
+
+        public SetMode(final String mode) {
+            this.mode = mode;
+        }
+
+        @SerializedName("mode")
+        public String mode = "";
+    }
+
+    public VeSyncRequestManagedDeviceBypassV2() {
+        super();
+        method = "bypassV2";
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncRequestManagedDevicesPage.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncRequestManagedDevicesPage.java
new file mode 100644 (file)
index 0000000..fa92d21
--- /dev/null
@@ -0,0 +1,53 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.dto.requests;
+
+import org.openhab.binding.vesync.internal.dto.responses.VeSyncUserSession;
+import org.openhab.binding.vesync.internal.exceptions.AuthenticationException;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link VeSyncRequestManagedDevicesPage} is the Java class as a DTO to hold login credentials for the Vesync
+ * API.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+public class VeSyncRequestManagedDevicesPage extends VeSyncAuthenticatedRequest {
+
+    @SerializedName("pageNo")
+    public String pageNo;
+
+    @SerializedName("pageSize")
+    public String pageSize;
+
+    public VeSyncRequestManagedDevicesPage(final VeSyncUserSession user) throws AuthenticationException {
+        super(user);
+        method = "devices";
+    }
+
+    public VeSyncRequestManagedDevicesPage(final VeSyncUserSession user, int pageNo, int pageSize)
+            throws AuthenticationException {
+        this(user);
+        this.pageNo = String.valueOf(pageNo);
+        this.pageSize = String.valueOf(pageSize);
+    }
+
+    public String getPageNo() {
+        return pageNo;
+    }
+
+    public String getPageSize() {
+        return pageSize;
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncRequestV1ManagedDeviceDetails.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncRequestV1ManagedDeviceDetails.java
new file mode 100644 (file)
index 0000000..5a2a950
--- /dev/null
@@ -0,0 +1,57 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.dto.requests;
+
+import org.openhab.binding.vesync.internal.dto.responses.VeSyncUserSession;
+import org.openhab.binding.vesync.internal.exceptions.AuthenticationException;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link VeSyncRequestV1ManagedDeviceDetails} is the Java class as a DTO to hold login credentials for the Vesync
+ * API.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+public class VeSyncRequestV1ManagedDeviceDetails extends VeSyncAuthenticatedRequest {
+
+    @SerializedName("mobileId")
+    public String mobileId = "1234567890123456";
+
+    @SerializedName("uuid")
+    public String uuid = null;
+
+    public VeSyncRequestV1ManagedDeviceDetails(final String deviceUuid) {
+        uuid = deviceUuid;
+        method = "deviceDetail";
+    }
+
+    public VeSyncRequestV1ManagedDeviceDetails(final VeSyncUserSession user) throws AuthenticationException {
+        super(user);
+        method = "deviceDetail";
+    }
+
+    public VeSyncRequestV1ManagedDeviceDetails(final VeSyncUserSession user, String deviceUuid)
+            throws AuthenticationException {
+        this(user);
+        uuid = deviceUuid;
+    }
+
+    public String getUuid() {
+        return uuid;
+    }
+
+    public String getMobileId() {
+        return mobileId;
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncLoginResponse.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncLoginResponse.java
new file mode 100644 (file)
index 0000000..fef91fd
--- /dev/null
@@ -0,0 +1,40 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.dto.responses;
+
+/**
+ * The {@link VeSyncLoginResponse} is a Java class used as a DTO to hold the Vesync's API's login response.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+public class VeSyncLoginResponse extends VeSyncResponse {
+
+    public VeSyncUserSession result;
+
+    public VeSyncUserSession getUserSession() {
+        return result;
+    }
+
+    public String getToken() {
+        return (result == null) ? null : result.token;
+    }
+
+    public String getAccountId() {
+        return (result == null) ? null : result.accountId;
+    }
+
+    @Override
+    public String toString() {
+        return "VesyncLoginResponse [msg=" + getMsg() + ", result=" + result + "]";
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncManagedDeviceBase.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncManagedDeviceBase.java
new file mode 100644 (file)
index 0000000..7f0823a
--- /dev/null
@@ -0,0 +1,142 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.dto.responses;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Contains basic information about a single device, from within a VeSyncManagedDevicesPage.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+public class VeSyncManagedDeviceBase {
+
+    @SerializedName("deviceRegion")
+    public String deviceRegion;
+
+    public String getDeviceRegion() {
+        return deviceRegion;
+    }
+
+    @SerializedName("deviceType")
+    public String deviceType;
+
+    public String getDeviceType() {
+        return deviceType;
+    }
+
+    @SerializedName("deviceName")
+    public String deviceName;
+
+    public String getDeviceName() {
+        return deviceName;
+    }
+
+    @SerializedName("deviceImg")
+    public String deviceImg;
+
+    public String getDeviceImg() {
+        return deviceImg;
+    }
+
+    @SerializedName("deviceStatus")
+    public String deviceStatus;
+
+    public String getDeviceStatus() {
+        return deviceStatus;
+    }
+
+    @SerializedName("cid")
+    public String cid;
+
+    public String getCid() {
+        return cid;
+    }
+
+    @SerializedName("connectionStatus")
+    public String connectionStatus;
+
+    public String getConnectionStatus() {
+        return connectionStatus;
+    }
+
+    @SerializedName("connectionType")
+    public String connectionType;
+
+    public String getConnectionType() {
+        return connectionType;
+    }
+
+    @SerializedName("type")
+    public String type;
+
+    public String getType() {
+        return type;
+    }
+
+    @SerializedName("subDeviceNo")
+    public String subDeviceNo;
+
+    public String getSubDeviceNo() {
+        return subDeviceNo;
+    }
+
+    @SerializedName("subDeviceType")
+    public String subDeviceType;
+
+    public String getSubDeviceType() {
+        return subDeviceType;
+    }
+
+    @SerializedName("uuid")
+    public String uuid;
+
+    public String getUuid() {
+        return uuid;
+    }
+
+    @SerializedName("macID")
+    public String macId;
+
+    public String getMacId() {
+        return macId;
+    }
+
+    @SerializedName("currentFirmVersion")
+    public String currentFirmVersion;
+
+    public String getCurrentFirmVersion() {
+        return currentFirmVersion;
+    }
+
+    @SerializedName("configModule")
+    public String configModule;
+
+    public String getConfigModule() {
+        return configModule;
+    }
+
+    @SerializedName("mode")
+    public String mode;
+
+    public String getMode() {
+        return mode;
+    }
+
+    @SerializedName("speed")
+    public String speed;
+
+    public String getSpeed() {
+        return speed;
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncManagedDevicesPage.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncManagedDevicesPage.java
new file mode 100644 (file)
index 0000000..0505715
--- /dev/null
@@ -0,0 +1,53 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.dto.responses;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link VeSyncManagedDevicesPage} is a Java class used as a DTO to hold the Vesync's API's response data to a
+ * page of data requesting the manages devices.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+public class VeSyncManagedDevicesPage extends VeSyncResponse {
+
+    @SerializedName("result")
+    public Outcome outcome;
+
+    public class Outcome {
+        @SerializedName("pageNo")
+        public String pageNo;
+
+        @SerializedName("total")
+        public String total;
+
+        @SerializedName("pageSize")
+        public String pageSize;
+
+        @SerializedName("list")
+        public VeSyncManagedDeviceBase[] list;
+
+        public String getPageNo() {
+            return pageNo;
+        }
+
+        public String getPageSize() {
+            return pageSize;
+        }
+
+        public String getTotal() {
+            return total;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncResponse.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncResponse.java
new file mode 100644 (file)
index 0000000..1370a63
--- /dev/null
@@ -0,0 +1,57 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.dto.responses;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link VeSyncResponse} is a Java class used as a DTO to hold the Vesync's API's common response data.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+public class VeSyncResponse {
+
+    @SerializedName("traceId")
+    public String traceId;
+
+    @SerializedName("code")
+    public String code;
+
+    @SerializedName("msg")
+    public String msg;
+
+    public String getMsg() {
+        return msg;
+    }
+
+    public String getTraceId() {
+        return traceId;
+    }
+
+    public String getCode() {
+        return code;
+    }
+
+    public boolean isMsgSuccess() {
+        return (msg != null) ? "request success".equals(msg) : false;
+    }
+
+    public boolean isMsgDeviceOffline() {
+        return (msg != null) ? "device offline".equals(msg) : false;
+    }
+
+    @Override
+    public String toString() {
+        return "VesyncResponse [traceId=\"" + traceId + "\", msg=\"" + msg + "\", code=\"" + code + "\"]";
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncResponseManagedDeviceBypassV2.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncResponseManagedDeviceBypassV2.java
new file mode 100644 (file)
index 0000000..d540f08
--- /dev/null
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.dto.responses;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link VeSyncResponseManagedDeviceBypassV2} is a Java class used as a DTO to hold the Vesync's API's common
+ * response data.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+public class VeSyncResponseManagedDeviceBypassV2 extends VeSyncResponse {
+
+    @SerializedName("result")
+    public ManagedDeviceByPassV2Payload result;
+
+    public class ManagedDeviceByPassV2Payload extends VeSyncResponse {
+
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncUserSession.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncUserSession.java
new file mode 100644 (file)
index 0000000..2b69ba6
--- /dev/null
@@ -0,0 +1,56 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.dto.responses;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Contains data about the logged in user - including the accountID and token's used
+ * for authenticating other payload's.
+ *
+ * @see unit test - Result may not be in respone if not authenticated
+ *
+ * @author David Goodyear - Initial contribution
+ */
+public class VeSyncUserSession {
+
+    public String token;
+
+    public String getToken() {
+        return token;
+    }
+
+    @SerializedName("registerTime")
+    public String registerTime;
+
+    @SerializedName("accountID")
+    public String accountId;
+
+    public String getAccountId() {
+        return accountId;
+    }
+
+    @SerializedName("registerAppVersion")
+    public String registerAppVersion;
+
+    @SerializedName("countryCode")
+    public String countryCode;
+
+    @SerializedName("acceptLanguage")
+    public String acceptLanguage;
+
+    @Override
+    public String toString() {
+        return "Data [user=AB" + ", token=" + token + "]";
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncV2BypassHumidifierStatus.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncV2BypassHumidifierStatus.java
new file mode 100644 (file)
index 0000000..6c0a41a
--- /dev/null
@@ -0,0 +1,88 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.dto.responses;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link VeSyncV2BypassHumidifierStatus} is a Java class used as a DTO to hold the Vesync's API's common response
+ * data, in regards to a Air Humidifier device.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+public class VeSyncV2BypassHumidifierStatus extends VeSyncResponse {
+
+    @SerializedName("result")
+    public HumidifierrStatus result;
+
+    public class HumidifierrStatus extends VeSyncResponse {
+
+        @SerializedName("result")
+        public AirHumidifierStatus result;
+
+        public class AirHumidifierStatus {
+            @SerializedName("enabled")
+            public boolean enabled;
+
+            @SerializedName("humidity")
+            public int humidity;
+
+            @SerializedName("mist_virtual_level")
+            public int mistVirtualLevel;
+
+            @SerializedName("mist_level")
+            public int mistLevel;
+
+            @SerializedName("mode")
+            public String mode;
+
+            @SerializedName("water_lacks")
+            public boolean waterLacks;
+
+            @SerializedName("humidity_high")
+            public boolean humidityHigh;
+
+            @SerializedName("water_tank_lifted")
+            public boolean waterTankLifted;
+
+            @SerializedName("display")
+            public boolean display;
+
+            @SerializedName("automatic_stop_reach_target")
+            public boolean automaticStopReachTarget;
+
+            @SerializedName("configuration")
+            public HumidityPurifierConfig configuration;
+
+            @SerializedName("night_light_brightness")
+            public int nightLightBrightness;
+
+            @SerializedName("warm_enabled")
+            public boolean warnEnabled;
+
+            @SerializedName("warm_level")
+            public int warmLevel;
+
+            public class HumidityPurifierConfig {
+                @SerializedName("auto_target_humidity")
+                public int autoTargetHumidity;
+
+                @SerializedName("display")
+                public boolean display;
+
+                @SerializedName("automatic_stop")
+                public boolean automaticStop;
+            }
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncV2BypassPurifierStatus.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncV2BypassPurifierStatus.java
new file mode 100644 (file)
index 0000000..ce88367
--- /dev/null
@@ -0,0 +1,99 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.dto.responses;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link VeSyncV2BypassPurifierStatus} is a Java class used as a DTO to hold the Vesync's API's common response
+ * data,
+ * in regards to a Air Purifier device.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+public class VeSyncV2BypassPurifierStatus extends VeSyncResponse {
+
+    @SerializedName("result")
+    public PurifierStatus result;
+
+    public class PurifierStatus extends VeSyncResponse {
+
+        @SerializedName("result")
+        public AirPurifierStatus result;
+
+        public class AirPurifierStatus {
+            @SerializedName("enabled")
+            public boolean enabled;
+
+            @SerializedName("filter_life")
+            public int filterLife;
+
+            @SerializedName("mode")
+            public String mode;
+
+            @SerializedName("level")
+            public int level;
+
+            @SerializedName("air_quality")
+            public int airQuality;
+
+            @SerializedName("air_quality_value")
+            public int airQualityValue;
+
+            @SerializedName("display")
+            public boolean display;
+
+            @SerializedName("child_lock")
+            public boolean childLock;
+
+            @SerializedName("night_light")
+            public String nightLight;
+
+            @SerializedName("configuration")
+            public AirPurifierConfig configuration;
+
+            public class AirPurifierConfig {
+                @SerializedName("display")
+                public boolean display;
+
+                @SerializedName("display_forever")
+                public boolean displayForever;
+
+                @SerializedName("auto_preference")
+                public AirPurifierConfigAutoPref autoPreference;
+
+                public class AirPurifierConfigAutoPref {
+                    @SerializedName("type")
+                    public String autoType;
+
+                    @SerializedName("room_size")
+                    public int roomSize;
+                }
+            }
+
+            @SerializedName("extension")
+            public AirPurifierExtension extension;
+
+            public class AirPurifierExtension {
+                @SerializedName("schedule_count")
+                public int scheduleCount;
+
+                @SerializedName("timer_remain")
+                public int timerRemain;
+            }
+
+            @SerializedName("device_error_code")
+            public int deviceErrorCode;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/v1/VeSyncV1AirPurifierDeviceDetailsResponse.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/v1/VeSyncV1AirPurifierDeviceDetailsResponse.java
new file mode 100644 (file)
index 0000000..b907caa
--- /dev/null
@@ -0,0 +1,101 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.dto.responses.v1;
+
+import org.openhab.binding.vesync.internal.dto.responses.VeSyncResponse;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link VeSyncV1AirPurifierDeviceDetailsResponse} is a Java class used as a DTO to hold the Vesync's V1 API's
+ * common response
+ * data, in regards to a Air Purifier device.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+public class VeSyncV1AirPurifierDeviceDetailsResponse extends VeSyncResponse {
+
+    @SerializedName("screenStatus")
+    public String screenStatus;
+
+    public String getScreenStatus() {
+        return screenStatus;
+    }
+
+    @SerializedName("airQuality")
+    public int airQuality;
+
+    public int getAirQuality() {
+        return airQuality;
+    }
+
+    @SerializedName("level")
+    public int level;
+
+    public int getLevel() {
+        return level;
+    }
+
+    @SerializedName("mode")
+    public String mode;
+
+    public String getMode() {
+        return mode;
+    }
+
+    @SerializedName("deviceName")
+    public String deviceName;
+
+    public String getDeviceName() {
+        return deviceName;
+    }
+
+    @SerializedName("currentFirmVersion")
+    public String currentFirmVersion;
+
+    public String getCurrentFirmVersion() {
+        return currentFirmVersion;
+    }
+
+    @SerializedName("childLock")
+    public String childLock;
+
+    public String getChildLock() {
+        return childLock;
+    }
+
+    @SerializedName("deviceStatus")
+    public String deviceStatus;
+
+    public String getDeviceStatus() {
+        return deviceStatus;
+    }
+
+    @SerializedName("deviceImg")
+    public String deviceImgUrl;
+
+    public String getDeviceImgUrl() {
+        return deviceImgUrl;
+    }
+
+    @SerializedName("connectionStatus")
+    public String connectionStatus;
+
+    public String getConnectionStatus() {
+        return connectionStatus;
+    }
+
+    public boolean isDeviceOnline() {
+        return "online".equals(connectionStatus);
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/exceptions/AuthenticationException.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/exceptions/AuthenticationException.java
new file mode 100644 (file)
index 0000000..5b9d6dd
--- /dev/null
@@ -0,0 +1,42 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.exceptions;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link AuthenticationException} is thrown if the authentication/login process is unsuccessful.
+ *
+ * @author David Godyear - Initial contribution
+ */
+@NonNullByDefault
+public class AuthenticationException extends Exception {
+
+    private static final long serialVersionUID = -7786425895604150557L;
+
+    public AuthenticationException() {
+        super();
+    }
+
+    public AuthenticationException(final String message) {
+        super(message);
+    }
+
+    public AuthenticationException(final Throwable cause) {
+        super(cause);
+    }
+
+    public AuthenticationException(final String message, final Throwable cause) {
+        super(message, cause);
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/exceptions/DeviceUnknownException.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/exceptions/DeviceUnknownException.java
new file mode 100644 (file)
index 0000000..783ff3f
--- /dev/null
@@ -0,0 +1,44 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.exceptions;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link DeviceUnknownException} is thrown if the device information could not be located for the address in
+ * relation
+ * to the API.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class DeviceUnknownException extends Exception {
+
+    private static final long serialVersionUID = -7786425642285150557L;
+
+    public DeviceUnknownException() {
+        super();
+    }
+
+    public DeviceUnknownException(final String message) {
+        super(message);
+    }
+
+    public DeviceUnknownException(final Throwable cause) {
+        super(cause);
+    }
+
+    public DeviceUnknownException(final String message, final Throwable cause) {
+        super(message, cause);
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/handlers/VeSyncBaseDeviceHandler.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/handlers/VeSyncBaseDeviceHandler.java
new file mode 100644 (file)
index 0000000..681c4fe
--- /dev/null
@@ -0,0 +1,508 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.handlers;
+
+import static org.openhab.binding.vesync.internal.VeSyncConstants.*;
+import static org.openhab.binding.vesync.internal.dto.requests.VeSyncProtocolConstants.V2_BYPASS_ENDPOINT;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+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.vesync.internal.VeSyncBridgeConfiguration;
+import org.openhab.binding.vesync.internal.VeSyncDeviceConfiguration;
+import org.openhab.binding.vesync.internal.dto.requests.VeSyncAuthenticatedRequest;
+import org.openhab.binding.vesync.internal.dto.requests.VeSyncRequestManagedDeviceBypassV2;
+import org.openhab.binding.vesync.internal.dto.responses.VeSyncManagedDeviceBase;
+import org.openhab.binding.vesync.internal.exceptions.AuthenticationException;
+import org.openhab.binding.vesync.internal.exceptions.DeviceUnknownException;
+import org.openhab.core.cache.ExpiringCache;
+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.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.builder.ThingBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link VeSyncBaseDeviceHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public abstract class VeSyncBaseDeviceHandler extends BaseThingHandler {
+
+    private final Logger logger = LoggerFactory.getLogger(VeSyncBaseDeviceHandler.class);
+
+    private static final String MARKER_INVALID_DEVICE_KEY = "---INVALID---";
+
+    @NotNull
+    protected String deviceLookupKey = MARKER_INVALID_DEVICE_KEY;
+
+    private static final int CACHE_TIMEOUT_SECOND = 5;
+
+    private int activePollRate = -2; // -1 is used to deactivate the poll, so default to a different value
+
+    private @Nullable ScheduledFuture<?> backgroundPollingScheduler;
+    private final Object pollConfigLock = new Object();
+
+    protected @Nullable VeSyncClient veSyncClient;
+
+    private volatile long latestReadBackMillis = 0;
+
+    @Nullable
+    ScheduledFuture<?> initialPollingTask = null;
+
+    @Nullable
+    ScheduledFuture<?> readbackPollTask = null;
+
+    public VeSyncBaseDeviceHandler(Thing thing) {
+        super(thing);
+    }
+
+    protected @Nullable Channel findChannelById(final String channelGroupId) {
+        return getThing().getChannel(channelGroupId);
+    }
+
+    protected ExpiringCache<String> lastPollResultCache = new ExpiringCache<>(Duration.ofSeconds(CACHE_TIMEOUT_SECOND),
+            VeSyncBaseDeviceHandler::expireCacheContents);
+
+    private static @Nullable String expireCacheContents() {
+        return null;
+    }
+
+    @Override
+    public void channelLinked(ChannelUID channelUID) {
+        super.channelLinked(channelUID);
+
+        scheduler.execute(this::pollForUpdate);
+    }
+
+    protected void setBackgroundPollInterval(final int seconds) {
+        if (activePollRate == seconds) {
+            return;
+        }
+        logger.debug("Reconfiguring devices background polling to {} seconds", seconds);
+
+        synchronized (pollConfigLock) {
+            final ScheduledFuture<?> job = backgroundPollingScheduler;
+
+            // Cancel the current scan's and re-schedule as required
+            if (job != null && !job.isCancelled()) {
+                job.cancel(true);
+                backgroundPollingScheduler = null;
+            }
+            if (seconds > 0) {
+                logger.trace("Device data is polling every {} seconds", seconds);
+                backgroundPollingScheduler = scheduler.scheduleWithFixedDelay(this::pollForUpdate, seconds, seconds,
+                        TimeUnit.SECONDS);
+            }
+            activePollRate = seconds;
+        }
+    }
+
+    public boolean requiresMetaDataFrequentUpdates() {
+        return (MARKER_INVALID_DEVICE_KEY.equals(deviceLookupKey));
+    }
+
+    private @Nullable BridgeHandler getBridgeHandler() {
+        Bridge bridgeRef = getBridge();
+        if (bridgeRef == null) {
+            return null;
+        } else {
+            return bridgeRef.getHandler();
+        }
+    }
+
+    protected boolean isDeviceOnline() {
+        BridgeHandler bridgeHandler = getBridgeHandler();
+        if (bridgeHandler != null && bridgeHandler instanceof VeSyncBridgeHandler) {
+            VeSyncBridgeHandler vesyncBridgeHandler = (VeSyncBridgeHandler) bridgeHandler;
+            @Nullable
+            VeSyncManagedDeviceBase metadata = vesyncBridgeHandler.api.getMacLookupMap().get(deviceLookupKey);
+
+            if (metadata == null) {
+                return false;
+            }
+
+            return ("online".equals(metadata.connectionStatus));
+        }
+        return false;
+    }
+
+    public void updateDeviceMetaData() {
+        Map<String, String> newProps = null;
+
+        BridgeHandler bridgeHandler = getBridgeHandler();
+        if (bridgeHandler != null && bridgeHandler instanceof VeSyncBridgeHandler) {
+            VeSyncBridgeHandler vesyncBridgeHandler = (VeSyncBridgeHandler) bridgeHandler;
+            @Nullable
+            VeSyncManagedDeviceBase metadata = vesyncBridgeHandler.api.getMacLookupMap().get(deviceLookupKey);
+
+            if (metadata == null) {
+                return;
+            }
+
+            newProps = getMetadataProperities(metadata);
+
+            // Refresh the device -> protocol mapping
+            deviceLookupKey = getValidatedIdString();
+
+            if ("online".equals(metadata.connectionStatus)) {
+                updateStatus(ThingStatus.ONLINE);
+            } else if ("offline".equals(metadata.connectionStatus)) {
+                updateStatus(ThingStatus.OFFLINE);
+            }
+        }
+
+        if (newProps != null && !newProps.isEmpty()) {
+            this.updateProperties(newProps);
+            removeChannels();
+            if (!isDeviceSupported()) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                        "Device Model or Type not supported by this thing");
+            }
+        }
+    }
+
+    /**
+     * Override this in classes that extend this, to
+     */
+    protected void customiseChannels() {
+    }
+
+    protected String[] getChannelsToRemove() {
+        return new String[] {};
+    }
+
+    private void removeChannels() {
+        final String[] channelsToRemove = getChannelsToRemove();
+        final List<Channel> channelsToBeRemoved = new ArrayList<>();
+        for (String name : channelsToRemove) {
+            Channel ch = getThing().getChannel(name);
+            if (ch != null) {
+                channelsToBeRemoved.add(ch);
+            }
+        }
+
+        final ThingBuilder builder = editThing().withoutChannels(channelsToBeRemoved);
+        updateThing(builder.build());
+    }
+
+    /**
+     * Extract the common properties for all devices, from the given meta-data of a device.
+     * 
+     * @param metadata - the meta-data of a device
+     * @return - Map of common props
+     */
+    public Map<String, String> getMetadataProperities(final @Nullable VeSyncManagedDeviceBase metadata) {
+        if (metadata == null) {
+            return Map.of();
+        }
+        final Map<String, String> newProps = new HashMap<>(4);
+        newProps.put(DEVICE_PROP_DEVICE_MAC_ID, metadata.getMacId());
+        newProps.put(DEVICE_PROP_DEVICE_NAME, metadata.getDeviceName());
+        newProps.put(DEVICE_PROP_DEVICE_TYPE, metadata.getDeviceType());
+        newProps.put(DEVICE_PROP_DEVICE_UUID, metadata.getUuid());
+        return newProps;
+    }
+
+    protected synchronized @Nullable VeSyncClient getVeSyncClient() {
+        if (veSyncClient == null) {
+            Bridge bridge = getBridge();
+            if (bridge == null) {
+                return null;
+            }
+            ThingHandler handler = bridge.getHandler();
+            if (handler instanceof VeSyncClient) {
+                veSyncClient = (VeSyncClient) handler;
+            } else {
+                return null;
+            }
+        }
+        return veSyncClient;
+    }
+
+    protected void requestBridgeFreqScanMetadataIfReq() {
+        if (requiresMetaDataFrequentUpdates()) {
+            BridgeHandler bridgeHandler = getBridgeHandler();
+            if (bridgeHandler != null && bridgeHandler instanceof VeSyncBridgeHandler) {
+                VeSyncBridgeHandler vesyncBridgeHandler = (VeSyncBridgeHandler) bridgeHandler;
+                vesyncBridgeHandler.checkIfIncreaseScanRateRequired();
+            }
+        }
+    }
+
+    @NotNull
+    public String getValidatedIdString() {
+        final VeSyncDeviceConfiguration config = getConfigAs(VeSyncDeviceConfiguration.class);
+
+        BridgeHandler bridgeHandler = getBridgeHandler();
+        if (bridgeHandler != null && bridgeHandler instanceof VeSyncBridgeHandler) {
+            VeSyncBridgeHandler vesyncBridgeHandler = (VeSyncBridgeHandler) bridgeHandler;
+
+            final String configMac = config.macId;
+
+            // Try to use the mac directly
+            if (configMac != null) {
+                logger.debug("Searching for device mac id : {}", configMac);
+                @Nullable
+                VeSyncManagedDeviceBase metadata = vesyncBridgeHandler.api.getMacLookupMap()
+                        .get(configMac.toLowerCase());
+
+                if (metadata != null && metadata.macId != null) {
+                    return metadata.macId;
+                }
+            }
+
+            final String deviceName = config.deviceName;
+
+            // Check if the device name can be matched to a single device
+            if (deviceName != null) {
+                final String[] matchedMacIds = vesyncBridgeHandler.api.getMacLookupMap().values().stream()
+                        .filter(x -> deviceName.equals(x.deviceName)).map(x -> x.macId).toArray(String[]::new);
+
+                for (String val : matchedMacIds) {
+                    logger.debug("Found MAC match on name with : {}", val);
+                }
+
+                if (matchedMacIds.length != 1) {
+                    return MARKER_INVALID_DEVICE_KEY;
+                }
+
+                if (vesyncBridgeHandler.api.getMacLookupMap().get(matchedMacIds[0]) != null) {
+                    return matchedMacIds[0];
+                }
+            }
+        }
+
+        return MARKER_INVALID_DEVICE_KEY;
+    }
+
+    @Override
+    public void initialize() {
+        intializeDeviceForUse();
+    }
+
+    private void intializeDeviceForUse() {
+        // Sanity check basic setup
+        final VeSyncBridgeHandler bridge = (VeSyncBridgeHandler) getBridgeHandler();
+        if (bridge == null) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED, "Missing bridge for API link");
+            return;
+        } else {
+            updateStatus(ThingStatus.UNKNOWN);
+        }
+
+        deviceLookupKey = getValidatedIdString();
+
+        // Populate device props - this is required for polling, to cross-check the device model.
+        updateDeviceMetaData();
+
+        // If the base device class marks it as offline there is an issue that will prevent normal operation
+        if (getThing().getStatus().equals(ThingStatus.OFFLINE)) {
+            return;
+        }
+        // This will force the bridge to push the configuration parameters for polling to the handler
+        bridge.updateThing(this);
+
+        // Give the bridge time to build the datamaps of the devices
+        scheduleInitialPoll();
+    }
+
+    private void scheduleInitialPoll() {
+        cancelInitialPoll(false);
+        initialPollingTask = scheduler.schedule(this::pollForUpdate, 10, TimeUnit.SECONDS);
+    }
+
+    private void cancelInitialPoll(final boolean interruptAllowed) {
+        final ScheduledFuture<?> pollJob = initialPollingTask;
+        if (pollJob != null && !pollJob.isCancelled()) {
+            pollJob.cancel(interruptAllowed);
+            initialPollingTask = null;
+        }
+    }
+
+    private void cancelReadbackPoll(final boolean interruptAllowed) {
+        final ScheduledFuture<?> pollJob = readbackPollTask;
+        if (pollJob != null && !pollJob.isCancelled()) {
+            pollJob.cancel(interruptAllowed);
+            readbackPollTask = null;
+        }
+    }
+
+    @Override
+    public void dispose() {
+        cancelReadbackPoll(true);
+        cancelInitialPoll(true);
+    }
+
+    public void pollForUpdate() {
+        pollForDeviceData(lastPollResultCache);
+    }
+
+    /**
+     * This should be implemented by subclasses to provide the implementation for polling the specific
+     * data for the type the class is responsible for. (Excluding meta data).
+     *
+     * @param cachedResponse - An Expiring cache that can be utilised to store the responses, to prevent poll bursts by
+     *            coalescing the requests.
+     */
+    protected abstract void pollForDeviceData(final ExpiringCache<String> cachedResponse);
+
+    /**
+     * Send a BypassV2 command to the device. The body of the response is returned, a poll is done if the request
+     * should have been dispatched.
+     * 
+     * @param method - the V2 bypass method
+     * @param payload - The payload to send in within the V2 bypass command
+     * @return - The body of the response, or EMPTY_STRING if the command could not be issued.
+     */
+    protected final String sendV2BypassControlCommand(final String method,
+            final VeSyncRequestManagedDeviceBypassV2.EmptyPayload payload) {
+        return sendV2BypassControlCommand(method, payload, true);
+    }
+
+    /**
+     * Send a BypassV2 command to the device. The body of the response is returned.
+     * 
+     * @param method - the V2 bypass method
+     * @param payload - The payload to send in within the V2 bypass command
+     * @param readbackDevice - if set to true after the command has been issued, whether a poll of the devices data
+     *            should be run.
+     * @return - The body of the response, or EMPTY_STRING if the command could not be issued.
+     */
+    protected final String sendV2BypassControlCommand(final String method,
+            final VeSyncRequestManagedDeviceBypassV2.EmptyPayload payload, final boolean readbackDevice) {
+        final String result = sendV2BypassCommand(method, payload);
+        if (!result.equals(EMPTY_STRING) && readbackDevice) {
+            performReadbackPoll();
+        }
+        return result;
+    }
+
+    public final String sendV1Command(final String method, final String url, final VeSyncAuthenticatedRequest request) {
+        if (ThingStatus.OFFLINE.equals(this.thing.getStatus())) {
+            logger.debug("Command blocked as device is offline");
+            return EMPTY_STRING;
+        }
+
+        try {
+            if (MARKER_INVALID_DEVICE_KEY.equals(deviceLookupKey)) {
+                deviceLookupKey = getValidatedIdString();
+            }
+            VeSyncClient client = getVeSyncClient();
+            if (client != null) {
+                return client.reqV2Authorized(url, deviceLookupKey, request);
+            } else {
+                throw new DeviceUnknownException("Missing client");
+            }
+        } catch (AuthenticationException e) {
+            logger.debug("Auth exception {}", e.getMessage());
+            return EMPTY_STRING;
+        } catch (final DeviceUnknownException e) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                    "Check configuration details - " + e.getMessage());
+            // In case the name is updated server side - request the scan rate is increased
+            requestBridgeFreqScanMetadataIfReq();
+            return EMPTY_STRING;
+        }
+    }
+
+    /**
+     * Send a BypassV2 command to the device. The body of the response is returned.
+     * 
+     * @param method - the V2 bypass method
+     * @param payload - The payload to send in within the V2 bypass command
+     * @return - The body of the response, or EMPTY_STRING if the command could not be issued.
+     */
+    protected final String sendV2BypassCommand(final String method,
+            final VeSyncRequestManagedDeviceBypassV2.EmptyPayload payload) {
+        if (ThingStatus.OFFLINE.equals(this.thing.getStatus())) {
+            logger.debug("Command blocked as device is offline");
+            return EMPTY_STRING;
+        }
+
+        VeSyncRequestManagedDeviceBypassV2 readReq = new VeSyncRequestManagedDeviceBypassV2();
+        readReq.payload.method = method;
+        readReq.payload.data = payload;
+
+        try {
+            if (MARKER_INVALID_DEVICE_KEY.equals(deviceLookupKey)) {
+                deviceLookupKey = getValidatedIdString();
+            }
+            VeSyncClient client = getVeSyncClient();
+            if (client != null) {
+                return client.reqV2Authorized(V2_BYPASS_ENDPOINT, deviceLookupKey, readReq);
+            } else {
+                throw new DeviceUnknownException("Missing client");
+            }
+        } catch (AuthenticationException e) {
+            logger.debug("Auth exception {}", e.getMessage());
+            return EMPTY_STRING;
+        } catch (final DeviceUnknownException e) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                    "Check configuration details - " + e.getMessage());
+            // In case the name is updated server side - request the scan rate is increased
+            requestBridgeFreqScanMetadataIfReq();
+            return EMPTY_STRING;
+        }
+    }
+
+    // Given several changes may be done at the same time, or in close proximity, delay the read-back to catch
+    // multiple read-back's, so a single update can handle them.
+    public void performReadbackPoll() {
+        final long requestSystemMillis = System.currentTimeMillis();
+        latestReadBackMillis = requestSystemMillis;
+        cancelReadbackPoll(false);
+        readbackPollTask = scheduler.schedule(() -> {
+            // This is a historical poll, ignore it
+            if (requestSystemMillis != latestReadBackMillis) {
+                logger.trace("Poll read-back cancelled, another later one is scheduled to happen");
+                return;
+            }
+            logger.trace("Read-back poll executing");
+            // Read-backs should never use the cached data - but may provide it for poll's that coincide with
+            // the caches alive duration.
+            lastPollResultCache.invalidateValue();
+            pollForUpdate();
+        }, 1L, TimeUnit.SECONDS);
+    }
+
+    public void updateBridgeBasedPolls(VeSyncBridgeConfiguration config) {
+    }
+
+    /**
+     * Subclasses should implement this method, and return true if the device is a model it can support
+     * interoperability with. If it cannot be determind to be a mode
+     *
+     * @return - true if the device is supported, false if the device isn't. E.g. Unknown model id in meta-data would
+     *         return false.
+     */
+    protected abstract boolean isDeviceSupported();
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/handlers/VeSyncBridgeHandler.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/handlers/VeSyncBridgeHandler.java
new file mode 100644 (file)
index 0000000..6294dcc
--- /dev/null
@@ -0,0 +1,241 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.handlers;
+
+import static org.openhab.binding.vesync.internal.VeSyncConstants.*;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+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.vesync.internal.VeSyncBridgeConfiguration;
+import org.openhab.binding.vesync.internal.api.IHttpClientProvider;
+import org.openhab.binding.vesync.internal.api.VeSyncV2ApiHelper;
+import org.openhab.binding.vesync.internal.discovery.DeviceMetaDataUpdatedHandler;
+import org.openhab.binding.vesync.internal.discovery.VeSyncDiscoveryService;
+import org.openhab.binding.vesync.internal.dto.requests.VeSyncAuthenticatedRequest;
+import org.openhab.binding.vesync.internal.dto.responses.VeSyncManagedDeviceBase;
+import org.openhab.binding.vesync.internal.dto.responses.VeSyncUserSession;
+import org.openhab.binding.vesync.internal.exceptions.AuthenticationException;
+import org.openhab.binding.vesync.internal.exceptions.DeviceUnknownException;
+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.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link VeSyncBridgeHandler} is responsible for handling the bridge things created to use the VeSync
+ * API. This way, the user credentials may be entered only once.
+ *
+ * @author David Goodyear - Initial Contribution
+ */
+@NonNullByDefault
+public class VeSyncBridgeHandler extends BaseBridgeHandler implements VeSyncClient {
+
+    private static final int DEFAULT_DEVICE_SCAN_INTERVAL = 600;
+    private static final int DEFAULT_DEVICE_SCAN_RECOVERY_INTERVAL = 60;
+    private static final int DEFAULT_DEVICE_SCAN_DISABLED = -1;
+
+    private final Logger logger = LoggerFactory.getLogger(VeSyncBridgeHandler.class);
+
+    private @Nullable ScheduledFuture<?> backgroundDiscoveryPollingJob;
+
+    protected final VeSyncV2ApiHelper api = new VeSyncV2ApiHelper();
+    private IHttpClientProvider httpClientProvider;
+
+    private volatile int backgroundScanTime = -1;
+    private final Object scanConfigLock = new Object();
+
+    public VeSyncBridgeHandler(Bridge bridge, @NotNull IHttpClientProvider httpClientProvider) {
+        super(bridge);
+        this.httpClientProvider = httpClientProvider;
+    }
+
+    public ThingUID getUID() {
+        return thing.getUID();
+    }
+
+    protected void checkIfIncreaseScanRateRequired() {
+        logger.trace("Checking if increased background scanning for new devices / base information is required");
+        boolean frequentScanReq = false;
+        for (Thing th : getThing().getThings()) {
+            ThingHandler handler = th.getHandler();
+            if (handler instanceof VeSyncBaseDeviceHandler) {
+                if (((VeSyncBaseDeviceHandler) handler).requiresMetaDataFrequentUpdates()) {
+                    frequentScanReq = true;
+                    break;
+                }
+            }
+        }
+
+        if (!frequentScanReq
+                && api.getMacLookupMap().values().stream().anyMatch(x -> "offline".equals(x.connectionStatus))) {
+            frequentScanReq = true;
+        }
+
+        if (frequentScanReq) {
+            setBackgroundScanInterval(DEFAULT_DEVICE_SCAN_RECOVERY_INTERVAL);
+        } else {
+            setBackgroundScanInterval(DEFAULT_DEVICE_SCAN_INTERVAL);
+        }
+    }
+
+    protected void setBackgroundScanInterval(final int seconds) {
+        synchronized (scanConfigLock) {
+            ScheduledFuture<?> job = backgroundDiscoveryPollingJob;
+            if (backgroundScanTime != seconds) {
+                if (seconds > 0) {
+                    logger.trace("Scheduling background scanning for new devices / base information every {} seconds",
+                            seconds);
+                } else {
+                    logger.trace("Disabling background scanning for new devices / base information");
+                }
+                // Cancel the current scan's and re-schedule as required
+                if (job != null && !job.isCancelled()) {
+                    job.cancel(true);
+                    backgroundDiscoveryPollingJob = null;
+                }
+                if (seconds > 0) {
+                    backgroundDiscoveryPollingJob = scheduler.scheduleWithFixedDelay(
+                            this::runDeviceScanSequenceNoAuthErrors, seconds, seconds, TimeUnit.SECONDS);
+                }
+                backgroundScanTime = seconds;
+            }
+        }
+    }
+
+    public void registerMetaDataUpdatedHandler(DeviceMetaDataUpdatedHandler dmduh) {
+        handlers.add(dmduh);
+    }
+
+    public void unregisterMetaDataUpdatedHandler(DeviceMetaDataUpdatedHandler dmduh) {
+        handlers.remove(dmduh);
+    }
+
+    private final CopyOnWriteArrayList<DeviceMetaDataUpdatedHandler> handlers = new CopyOnWriteArrayList<>();
+
+    public void runDeviceScanSequenceNoAuthErrors() {
+        try {
+            runDeviceScanSequence();
+            updateStatus(ThingStatus.ONLINE);
+        } catch (AuthenticationException ae) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Check login credentials");
+        }
+    }
+
+    public void runDeviceScanSequence() throws AuthenticationException {
+        logger.trace("Scanning for new devices / base information now");
+        api.discoverDevices();
+        handlers.forEach(x -> x.handleMetadataRetrieved(this));
+        checkIfIncreaseScanRateRequired();
+
+        this.updateThings();
+    }
+
+    public java.util.stream.Stream<@NotNull VeSyncManagedDeviceBase> getAirPurifiersMetadata() {
+        return api.getMacLookupMap().values().stream()
+                .filter(x -> VeSyncDeviceAirPurifierHandler.SUPPORTED_DEVICE_TYPES.contains(x.deviceType));
+    }
+
+    public java.util.stream.Stream<@NotNull VeSyncManagedDeviceBase> getAirHumidifiersMetadata() {
+        return api.getMacLookupMap().values().stream()
+                .filter(x -> VeSyncDeviceAirHumidifierHandler.SUPPORTED_DEVICE_TYPES.contains(x.deviceType));
+    }
+
+    protected void updateThings() {
+        final VeSyncBridgeConfiguration config = getConfigAs(VeSyncBridgeConfiguration.class);
+        getThing().getThings().forEach((th) -> updateThing(config, th.getHandler()));
+    }
+
+    public void updateThing(ThingHandler handler) {
+        final VeSyncBridgeConfiguration config = getConfigAs(VeSyncBridgeConfiguration.class);
+        updateThing(config, handler);
+    }
+
+    private void updateThing(VeSyncBridgeConfiguration config, @Nullable ThingHandler handler) {
+        if (handler instanceof VeSyncBaseDeviceHandler) {
+            ((VeSyncBaseDeviceHandler) handler).updateDeviceMetaData();
+            ((VeSyncBaseDeviceHandler) handler).updateBridgeBasedPolls(config);
+        }
+    }
+
+    @Override
+    public Collection<Class<? extends ThingHandlerService>> getServices() {
+        return Collections.singleton(VeSyncDiscoveryService.class);
+    }
+
+    @Override
+    public void initialize() {
+        api.setHttpClient(httpClientProvider.getHttpClient());
+
+        VeSyncBridgeConfiguration config = getConfigAs(VeSyncBridgeConfiguration.class);
+
+        scheduler.submit(() -> {
+            final String passwordMd5 = VeSyncV2ApiHelper.calculateMd5(config.password);
+
+            try {
+                api.login(config.username, passwordMd5, "Europe/London");
+                api.updateBridgeData(this);
+                runDeviceScanSequence();
+                updateStatus(ThingStatus.ONLINE);
+            } catch (final AuthenticationException ae) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Check login credentials");
+                // The background scan will keep trying to authenticate in case the users credentials are updated on the
+                // veSync servers,
+                // to match the binding's configuration.
+            }
+        });
+    }
+
+    @Override
+    public void dispose() {
+        setBackgroundScanInterval(DEFAULT_DEVICE_SCAN_DISABLED);
+        api.setHttpClient(null);
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command command) {
+        logger.warn("Handling command for VeSync bridge handler.");
+    }
+
+    public void handleNewUserSession(final @Nullable VeSyncUserSession userSessionData) {
+        final Map<String, String> newProps = new HashMap<>();
+        if (userSessionData != null) {
+            newProps.put(DEVICE_PROP_BRIDGE_REG_TS, userSessionData.registerTime);
+            newProps.put(DEVICE_PROP_BRIDGE_COUNTRY_CODE, userSessionData.countryCode);
+            newProps.put(DEVICE_PROP_BRIDGE_ACCEPT_LANG, userSessionData.acceptLanguage);
+        }
+        this.updateProperties(newProps);
+    }
+
+    public String reqV2Authorized(final String url, final String macId, final VeSyncAuthenticatedRequest requestData)
+            throws AuthenticationException, DeviceUnknownException {
+        return api.reqV2Authorized(url, macId, requestData);
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/handlers/VeSyncClient.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/handlers/VeSyncClient.java
new file mode 100644 (file)
index 0000000..2eb1c5a
--- /dev/null
@@ -0,0 +1,29 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.handlers;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.vesync.internal.dto.requests.VeSyncAuthenticatedRequest;
+import org.openhab.binding.vesync.internal.exceptions.AuthenticationException;
+import org.openhab.binding.vesync.internal.exceptions.DeviceUnknownException;
+
+/**
+ * The {@link VeSyncClient} is TBC.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public interface VeSyncClient {
+    String reqV2Authorized(final String url, final String macId, final VeSyncAuthenticatedRequest requestData)
+            throws AuthenticationException, DeviceUnknownException;
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/handlers/VeSyncDeviceAirHumidifierHandler.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/handlers/VeSyncDeviceAirHumidifierHandler.java
new file mode 100644 (file)
index 0000000..30ca49f
--- /dev/null
@@ -0,0 +1,349 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.handlers;
+
+import static org.openhab.binding.vesync.internal.VeSyncConstants.*;
+import static org.openhab.binding.vesync.internal.dto.requests.VeSyncProtocolConstants.*;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.vesync.internal.VeSyncBridgeConfiguration;
+import org.openhab.binding.vesync.internal.VeSyncConstants;
+import org.openhab.binding.vesync.internal.dto.requests.VeSyncRequestManagedDeviceBypassV2;
+import org.openhab.binding.vesync.internal.dto.responses.VeSyncV2BypassHumidifierStatus;
+import org.openhab.core.cache.ExpiringCache;
+import org.openhab.core.library.types.DecimalType;
+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.Units;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link VeSyncDeviceAirHumidifierHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class VeSyncDeviceAirHumidifierHandler extends VeSyncBaseDeviceHandler {
+
+    public static final int DEFAULT_AIR_PURIFIER_POLL_RATE = 120;
+    // "Device Type" values
+    public static final String DEV_TYPE_DUAL_200S = "Dual200S";
+    public static final String DEV_TYPE_CLASSIC_200S = "Classic200S";
+    public static final String DEV_TYPE_CORE_301S = "LUH-D301S-WEU";
+    public static final String DEV_TYPE_CLASSIC_300S = "Classic300S";
+    public static final String DEV_TYPE_600S = "LUH-A602S-WUS";
+    public static final String DEV_TYPE_600S_EU = "LUH-A602S-WEU";
+
+    private static final List<String> CLASSIC_300S_600S_MODES = Arrays.asList(MODE_AUTO, MODE_MANUAL, MODE_SLEEP);
+    private static final List<String> CLASSIC_300S_NIGHT_LIGHT_MODES = Arrays.asList(MODE_ON, MODE_DIM, MODE_OFF);
+
+    public static final List<String> SUPPORTED_DEVICE_TYPES = List.of(DEV_TYPE_DUAL_200S, DEV_TYPE_CLASSIC_200S,
+            DEV_TYPE_CLASSIC_300S, DEV_TYPE_CORE_301S, DEV_TYPE_600S, DEV_TYPE_600S_EU);
+
+    private final Logger logger = LoggerFactory.getLogger(VeSyncDeviceAirHumidifierHandler.class);
+
+    public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_AIR_HUMIDIFIER);
+
+    private final Object pollLock = new Object();
+
+    public VeSyncDeviceAirHumidifierHandler(Thing thing) {
+        super(thing);
+    }
+
+    @Override
+    protected String[] getChannelsToRemove() {
+        String[] toRemove = new String[] {};
+        final String deviceType = getThing().getProperties().get(DEVICE_PROP_DEVICE_TYPE);
+        if (deviceType != null) {
+            switch (deviceType) {
+                case DEV_TYPE_CLASSIC_300S:
+                    toRemove = new String[] { DEVICE_CHANNEL_WARM_ENABLED, DEVICE_CHANNEL_WARM_LEVEL };
+                    break;
+                case DEV_TYPE_DUAL_200S:
+                case DEV_TYPE_CLASSIC_200S:
+                case DEV_TYPE_CORE_301S:
+                    toRemove = new String[] { DEVICE_CHANNEL_WARM_ENABLED, DEVICE_CHANNEL_WARM_LEVEL,
+                            DEVICE_CHANNEL_AF_NIGHT_LIGHT };
+                    break;
+                case DEV_TYPE_600S:
+                case DEV_TYPE_600S_EU:
+                    toRemove = new String[] { DEVICE_CHANNEL_AF_NIGHT_LIGHT };
+                    break;
+            }
+        }
+        return toRemove;
+    }
+
+    @Override
+    public void initialize() {
+        super.initialize();
+        customiseChannels();
+    }
+
+    @Override
+    public void updateBridgeBasedPolls(final VeSyncBridgeConfiguration config) {
+        Integer pollRate = config.airPurifierPollInterval;
+        if (pollRate == null) {
+            pollRate = DEFAULT_AIR_PURIFIER_POLL_RATE;
+        }
+        if (ThingStatus.OFFLINE.equals(getThing().getStatus())) {
+            setBackgroundPollInterval(-1);
+        } else {
+            setBackgroundPollInterval(pollRate);
+        }
+    }
+
+    @Override
+    public void dispose() {
+        this.setBackgroundPollInterval(-1);
+    }
+
+    @Override
+    protected boolean isDeviceSupported() {
+        final String deviceType = getThing().getProperties().get(DEVICE_PROP_DEVICE_TYPE);
+        if (deviceType == null) {
+            return false;
+        }
+        return SUPPORTED_DEVICE_TYPES.contains(deviceType);
+    }
+
+    @Override
+    public void handleCommand(final ChannelUID channelUID, final Command command) {
+        final String deviceType = getThing().getProperties().get(DEVICE_PROP_DEVICE_TYPE);
+        if (deviceType == null) {
+            return;
+        }
+
+        scheduler.submit(() -> {
+
+            if (command instanceof OnOffType) {
+                switch (channelUID.getId()) {
+                    case DEVICE_CHANNEL_ENABLED:
+                        sendV2BypassControlCommand(DEVICE_SET_SWITCH,
+                                new VeSyncRequestManagedDeviceBypassV2.SetSwitchPayload(command.equals(OnOffType.ON),
+                                        0));
+                        break;
+                    case DEVICE_CHANNEL_DISPLAY_ENABLED:
+                        sendV2BypassControlCommand(DEVICE_SET_DISPLAY,
+                                new VeSyncRequestManagedDeviceBypassV2.SetState(command.equals(OnOffType.ON)));
+                        break;
+                    case DEVICE_CHANNEL_STOP_AT_TARGET:
+                        sendV2BypassControlCommand(DEVICE_SET_AUTOMATIC_STOP,
+                                new VeSyncRequestManagedDeviceBypassV2.EnabledPayload(command.equals(OnOffType.ON)));
+                        break;
+                    case DEVICE_CHANNEL_WARM_ENABLED:
+                        logger.warn("Warm mode API is unknown in order to send the command");
+                        break;
+                }
+            } else if (command instanceof QuantityType) {
+                switch (channelUID.getId()) {
+                    case DEVICE_CHANNEL_CONFIG_TARGET_HUMIDITY:
+                        int targetHumidity = ((QuantityType<?>) command).intValue();
+                        if (targetHumidity < 30) {
+                            logger.warn("Target Humidity less than 30 - adjusting to 30 as the valid API value");
+                            targetHumidity = 30;
+                        } else if (targetHumidity > 80) {
+                            logger.warn("Target Humidity greater than 80 - adjusting to 80 as the valid API value");
+                            targetHumidity = 80;
+                        }
+
+                        sendV2BypassControlCommand(DEVICE_SET_HUMIDITY_MODE,
+                                new VeSyncRequestManagedDeviceBypassV2.SetMode(MODE_AUTO), false);
+
+                        sendV2BypassControlCommand(DEVICE_SET_TARGET_HUMIDITY_MODE,
+                                new VeSyncRequestManagedDeviceBypassV2.SetTargetHumidity(targetHumidity));
+                        break;
+                    case DEVICE_CHANNEL_MIST_LEVEL:
+                        int targetMistLevel = ((QuantityType<?>) command).intValue();
+                        // If more devices have this the hope is it's those with the prefix LUH so the check can
+                        // be simplified, originally devices mapped 1/5/9 to 1/2/3.
+                        if (DEV_TYPE_CORE_301S.equals(deviceType)) {
+                            if (targetMistLevel < 1) {
+                                logger.warn("Target Mist Level less than 1 - adjusting to 1 as the valid API value");
+                                targetMistLevel = 1;
+                            } else if (targetMistLevel > 2) {
+                                logger.warn("Target Mist Level greater than 2 - adjusting to 2 as the valid API value");
+                                targetMistLevel = 2;
+                            }
+                        } else {
+                            if (targetMistLevel < 1) {
+                                logger.warn("Target Mist Level less than 1 - adjusting to 1 as the valid API value");
+                                targetMistLevel = 1;
+                            } else if (targetMistLevel > 3) {
+                                logger.warn("Target Mist Level greater than 3 - adjusting to 3 as the valid API value");
+                                targetMistLevel = 3;
+                            }
+                            // Re-map to what appears to be bitwise encoding of the states
+                            switch (targetMistLevel) {
+                                case 1:
+                                    targetMistLevel = 1;
+                                    break;
+                                case 2:
+                                    targetMistLevel = 5;
+                                    break;
+                                case 3:
+                                    targetMistLevel = 9;
+                                    break;
+                            }
+                        }
+
+                        sendV2BypassControlCommand(DEVICE_SET_HUMIDITY_MODE,
+                                new VeSyncRequestManagedDeviceBypassV2.SetMode(MODE_MANUAL), false);
+
+                        sendV2BypassControlCommand(DEVICE_SET_VIRTUAL_LEVEL,
+                                new VeSyncRequestManagedDeviceBypassV2.SetLevelPayload(0, DEVICE_LEVEL_TYPE_MIST,
+                                        targetMistLevel));
+                        break;
+                    case DEVICE_CHANNEL_WARM_LEVEL:
+                        logger.warn("Warm level API is unknown in order to send the command");
+                        break;
+                }
+            } else if (command instanceof StringType) {
+                final String targetMode = command.toString().toLowerCase();
+                switch (channelUID.getId()) {
+                    case DEVICE_CHANNEL_HUMIDIFIER_MODE:
+                        if (!CLASSIC_300S_600S_MODES.contains(targetMode)) {
+                            logger.warn(
+                                    "Humidifier mode command for \"{}\" is not valid in the (Classic300S/600S) API possible options {}",
+                                    command, String.join(",", CLASSIC_300S_NIGHT_LIGHT_MODES));
+                            return;
+                        }
+                        sendV2BypassControlCommand(DEVICE_SET_HUMIDITY_MODE,
+                                new VeSyncRequestManagedDeviceBypassV2.SetMode(targetMode));
+                        break;
+                    case DEVICE_CHANNEL_AF_NIGHT_LIGHT:
+                        if (!DEV_TYPE_CLASSIC_300S.equals(deviceType) && !DEV_TYPE_CORE_301S.equals(deviceType)) {
+                            logger.warn("Humidifier night light is not valid for your device ({}})", deviceType);
+                            return;
+                        }
+                        if (!CLASSIC_300S_NIGHT_LIGHT_MODES.contains(targetMode)) {
+                            logger.warn(
+                                    "Humidifier night light mode command for \"{}\" is not valid in the (Classic300S) API possible options {}",
+                                    command, String.join(",", CLASSIC_300S_NIGHT_LIGHT_MODES));
+                            return;
+                        }
+                        int targetValue;
+                        switch (targetMode) {
+                            case MODE_OFF:
+                                targetValue = 0;
+                                break;
+                            case MODE_DIM:
+                                targetValue = 50;
+                                break;
+                            case MODE_ON:
+                                targetValue = 100;
+                                break;
+                            default:
+                                return; // should never hit
+                        }
+                        sendV2BypassControlCommand(DEVICE_SET_NIGHT_LIGHT_BRIGHTNESS,
+                                new VeSyncRequestManagedDeviceBypassV2.SetNightLightBrightness(targetValue));
+                }
+            } else if (command instanceof RefreshType) {
+                pollForUpdate();
+            } else {
+                logger.trace("UNKNOWN COMMAND: {} {}", command.getClass().toString(), channelUID);
+            }
+        });
+    }
+
+    @Override
+    protected void pollForDeviceData(final ExpiringCache<String> cachedResponse) {
+        String response;
+        VeSyncV2BypassHumidifierStatus humidifierStatus;
+        synchronized (pollLock) {
+            response = cachedResponse.getValue();
+            boolean cachedDataUsed = response != null;
+            if (response == null) {
+                logger.trace("Requesting fresh response");
+                response = sendV2BypassCommand(DEVICE_GET_HUMIDIFIER_STATUS,
+                        new VeSyncRequestManagedDeviceBypassV2.EmptyPayload());
+            } else {
+                logger.trace("Using cached response {}", response);
+            }
+
+            if (response.equals(EMPTY_STRING)) {
+                return;
+            }
+
+            humidifierStatus = VeSyncConstants.GSON.fromJson(response, VeSyncV2BypassHumidifierStatus.class);
+
+            if (humidifierStatus == null) {
+                return;
+            }
+
+            if (!cachedDataUsed) {
+                cachedResponse.putValue(response);
+            }
+        }
+
+        // Bail and update the status of the thing - it will be updated to online by the next search
+        // that detects it is online.
+        if (humidifierStatus.isMsgDeviceOffline()) {
+            updateStatus(ThingStatus.OFFLINE);
+            return;
+        } else if (humidifierStatus.isMsgSuccess()) {
+            updateStatus(ThingStatus.ONLINE);
+        }
+
+        if (!"0".equals(humidifierStatus.result.getCode())) {
+            logger.warn("Check correct Thing type has been set - API gave a unexpected response for an Air Humidifier");
+            return;
+        }
+
+        final String deviceType = getThing().getProperties().get(DEVICE_PROP_DEVICE_TYPE);
+
+        updateState(DEVICE_CHANNEL_ENABLED, OnOffType.from(humidifierStatus.result.result.enabled));
+        updateState(DEVICE_CHANNEL_DISPLAY_ENABLED, OnOffType.from(humidifierStatus.result.result.display));
+        updateState(DEVICE_CHANNEL_WATER_LACKS, OnOffType.from(humidifierStatus.result.result.waterLacks));
+        updateState(DEVICE_CHANNEL_HUMIDITY_HIGH, OnOffType.from(humidifierStatus.result.result.humidityHigh));
+        updateState(DEVICE_CHANNEL_WATER_TANK_LIFTED, OnOffType.from(humidifierStatus.result.result.waterTankLifted));
+        updateState(DEVICE_CHANNEL_STOP_AT_TARGET,
+                OnOffType.from(humidifierStatus.result.result.automaticStopReachTarget));
+        updateState(DEVICE_CHANNEL_HUMIDITY,
+                new QuantityType<>(humidifierStatus.result.result.humidity, Units.PERCENT));
+        updateState(DEVICE_CHANNEL_MIST_LEVEL, new DecimalType(humidifierStatus.result.result.mistLevel));
+        updateState(DEVICE_CHANNEL_HUMIDIFIER_MODE, new StringType(humidifierStatus.result.result.mode));
+
+        // Only the 300S supports nightlight currently of tested devices.
+        if (DEV_TYPE_CLASSIC_300S.equals(deviceType) || DEV_TYPE_CORE_301S.equals(deviceType)) {
+            // Map the numeric that only applies to the same modes as the Air Filter 300S series.
+            if (humidifierStatus.result.result.nightLightBrightness == 0) {
+                updateState(DEVICE_CHANNEL_AF_NIGHT_LIGHT, new StringType(MODE_OFF));
+            } else if (humidifierStatus.result.result.nightLightBrightness == 100) {
+                updateState(DEVICE_CHANNEL_AF_NIGHT_LIGHT, new StringType(MODE_ON));
+            } else {
+                updateState(DEVICE_CHANNEL_AF_NIGHT_LIGHT, new StringType(MODE_DIM));
+            }
+        } else if (DEV_TYPE_600S.equals(deviceType) || DEV_TYPE_600S_EU.equals(deviceType)) {
+            updateState(DEVICE_CHANNEL_WARM_ENABLED, OnOffType.from(humidifierStatus.result.result.warnEnabled));
+            updateState(DEVICE_CHANNEL_WARM_LEVEL, new DecimalType(humidifierStatus.result.result.warmLevel));
+        }
+
+        updateState(DEVICE_CHANNEL_CONFIG_TARGET_HUMIDITY,
+                new QuantityType<>(humidifierStatus.result.result.configuration.autoTargetHumidity, Units.PERCENT));
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/handlers/VeSyncDeviceAirPurifierHandler.java b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/handlers/VeSyncDeviceAirPurifierHandler.java
new file mode 100644 (file)
index 0000000..2d02cc7
--- /dev/null
@@ -0,0 +1,423 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.handlers;
+
+import static org.openhab.binding.vesync.internal.VeSyncConstants.*;
+import static org.openhab.binding.vesync.internal.dto.requests.VeSyncProtocolConstants.*;
+
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+
+import javax.validation.constraints.NotNull;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.vesync.internal.VeSyncBridgeConfiguration;
+import org.openhab.binding.vesync.internal.VeSyncConstants;
+import org.openhab.binding.vesync.internal.dto.requests.VeSyncRequestManagedDeviceBypassV2;
+import org.openhab.binding.vesync.internal.dto.requests.VeSyncRequestV1ManagedDeviceDetails;
+import org.openhab.binding.vesync.internal.dto.responses.VeSyncV2BypassPurifierStatus;
+import org.openhab.binding.vesync.internal.dto.responses.v1.VeSyncV1AirPurifierDeviceDetailsResponse;
+import org.openhab.core.cache.ExpiringCache;
+import org.openhab.core.library.items.DateTimeItem;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.DecimalType;
+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.Units;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link VeSyncDeviceAirPurifierHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class VeSyncDeviceAirPurifierHandler extends VeSyncBaseDeviceHandler {
+
+    public static final int DEFAULT_AIR_PURIFIER_POLL_RATE = 120;
+    // "Device Type" values
+    public static final String DEV_TYPE_CORE_600S = "LAP-C601S-WUS";
+    public static final String DEV_TYPE_CORE_400S = "Core400S";
+    public static final String DEV_TYPE_CORE_300S = "Core300S";
+    public static final String DEV_TYPE_CORE_201S = "LAP-C201S-AUSR";
+    public static final String DEV_TYPE_CORE_200S = "Core200S";
+    public static final String DEV_TYPE_LV_PUR131S = "LV-PUR131S";
+    public static final List<String> SUPPORTED_DEVICE_TYPES = Arrays.asList(DEV_TYPE_CORE_600S, DEV_TYPE_CORE_400S,
+            DEV_TYPE_CORE_300S, DEV_TYPE_CORE_201S, DEV_TYPE_CORE_200S, DEV_TYPE_LV_PUR131S);
+
+    private static final List<String> CORE_400S600S_FAN_MODES = Arrays.asList(MODE_AUTO, MODE_MANUAL, MODE_SLEEP);
+    private static final List<String> CORE_200S300S_FAN_MODES = Arrays.asList(MODE_MANUAL, MODE_SLEEP);
+    private static final List<String> CORE_200S300S_NIGHT_LIGHT_MODES = Arrays.asList(MODE_ON, MODE_DIM, MODE_OFF);
+
+    private final Logger logger = LoggerFactory.getLogger(VeSyncDeviceAirPurifierHandler.class);
+
+    public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_AIR_PURIFIER);
+
+    private final Object pollLock = new Object();
+
+    public VeSyncDeviceAirPurifierHandler(Thing thing) {
+        super(thing);
+    }
+
+    @Override
+    public void initialize() {
+        super.initialize();
+        customiseChannels();
+    }
+
+    @Override
+    protected @NotNull String[] getChannelsToRemove() {
+        String[] toRemove = new String[] {};
+        final String deviceType = getThing().getProperties().get(DEVICE_PROP_DEVICE_TYPE);
+        if (deviceType != null) {
+            switch (deviceType) {
+                case DEV_TYPE_CORE_600S:
+                case DEV_TYPE_CORE_400S:
+                    toRemove = new String[] { DEVICE_CHANNEL_AF_NIGHT_LIGHT };
+                    break;
+                case DEV_TYPE_LV_PUR131S:
+                    toRemove = new String[] { DEVICE_CHANNEL_AF_NIGHT_LIGHT, DEVICE_CHANNEL_AF_CONFIG_AUTO_ROOM_SIZE,
+                            DEVICE_CHANNEL_AF_CONFIG_AUTO_MODE_PREF, DEVICE_CHANNEL_AF_AUTO_OFF_CALC_TIME,
+                            DEVICE_CHANNEL_AIR_FILTER_LIFE_PERCENTAGE_REMAINING, DEVICE_CHANNEL_AIRQUALITY_PM25,
+                            DEVICE_CHANNEL_AF_SCHEDULES_COUNT, DEVICE_CHANNEL_AF_CONFIG_DISPLAY_FOREVER };
+                    break;
+                default:
+                    toRemove = new String[] { DEVICE_CHANNEL_AF_AUTO_OFF_CALC_TIME, DEVICE_CHANNEL_AF_SCHEDULES_COUNT };
+            }
+        }
+        return toRemove;
+    }
+
+    @Override
+    public void updateBridgeBasedPolls(final VeSyncBridgeConfiguration config) {
+        Integer pollRate = config.airPurifierPollInterval;
+        if (pollRate == null) {
+            pollRate = DEFAULT_AIR_PURIFIER_POLL_RATE;
+        }
+
+        if (ThingStatus.OFFLINE.equals(getThing().getStatus())) {
+            setBackgroundPollInterval(-1);
+        } else {
+            setBackgroundPollInterval(pollRate);
+        }
+    }
+
+    @Override
+    public void dispose() {
+        this.setBackgroundPollInterval(-1);
+    }
+
+    @Override
+    protected boolean isDeviceSupported() {
+        final String deviceType = getThing().getProperties().get(DEVICE_PROP_DEVICE_TYPE);
+        if (deviceType == null) {
+            return false;
+        }
+        return SUPPORTED_DEVICE_TYPES.contains(deviceType);
+    }
+
+    @Override
+    public void handleCommand(final ChannelUID channelUID, final Command command) {
+        final String deviceType = getThing().getProperties().get(DEVICE_PROP_DEVICE_TYPE);
+        if (deviceType == null) {
+            return;
+        }
+
+        scheduler.submit(() -> {
+
+            if (command instanceof OnOffType) {
+                switch (channelUID.getId()) {
+                    case DEVICE_CHANNEL_ENABLED:
+                        sendV2BypassControlCommand(DEVICE_SET_SWITCH,
+                                new VeSyncRequestManagedDeviceBypassV2.SetSwitchPayload(command.equals(OnOffType.ON),
+                                        0));
+                        break;
+                    case DEVICE_CHANNEL_DISPLAY_ENABLED:
+                        sendV2BypassControlCommand(DEVICE_SET_DISPLAY,
+                                new VeSyncRequestManagedDeviceBypassV2.SetState(command.equals(OnOffType.ON)));
+                        break;
+                    case DEVICE_CHANNEL_CHILD_LOCK_ENABLED:
+                        sendV2BypassControlCommand(DEVICE_SET_CHILD_LOCK,
+                                new VeSyncRequestManagedDeviceBypassV2.SetChildLock(command.equals(OnOffType.ON)));
+                        break;
+                }
+            } else if (command instanceof StringType) {
+                switch (channelUID.getId()) {
+                    case DEVICE_CHANNEL_FAN_MODE_ENABLED:
+                        final String targetFanMode = command.toString().toLowerCase();
+                        switch (deviceType) {
+                            case DEV_TYPE_CORE_600S:
+                            case DEV_TYPE_CORE_400S:
+                                if (!CORE_400S600S_FAN_MODES.contains(targetFanMode)) {
+                                    logger.warn(
+                                            "Fan mode command for \"{}\" is not valid in the (Core400S) API possible options {}",
+                                            command, String.join(",", CORE_400S600S_FAN_MODES));
+                                    return;
+                                }
+                                break;
+                            case DEV_TYPE_CORE_200S:
+                            case DEV_TYPE_CORE_201S:
+                            case DEV_TYPE_CORE_300S:
+                                if (!CORE_200S300S_FAN_MODES.contains(targetFanMode)) {
+                                    logger.warn(
+                                            "Fan mode command for \"{}\" is not valid in the (Core200S/Core300S) API possible options {}",
+                                            command, String.join(",", CORE_200S300S_FAN_MODES));
+                                    return;
+                                }
+                                break;
+                        }
+
+                        sendV2BypassControlCommand(DEVICE_SET_PURIFIER_MODE,
+                                new VeSyncRequestManagedDeviceBypassV2.SetMode(targetFanMode));
+                        break;
+                    case DEVICE_CHANNEL_AF_NIGHT_LIGHT:
+                        final String targetNightLightMode = command.toString().toLowerCase();
+                        switch (deviceType) {
+                            case DEV_TYPE_CORE_600S:
+                            case DEV_TYPE_CORE_400S:
+                                logger.warn("Core400S API does not support night light");
+                                return;
+                            case DEV_TYPE_CORE_200S:
+                            case DEV_TYPE_CORE_201S:
+                            case DEV_TYPE_CORE_300S:
+                                if (!CORE_200S300S_NIGHT_LIGHT_MODES.contains(targetNightLightMode)) {
+                                    logger.warn(
+                                            "Night light mode command for \"{}\" is not valid in the (Core200S/Core300S) API possible options {}",
+                                            command, String.join(",", CORE_200S300S_NIGHT_LIGHT_MODES));
+                                    return;
+                                }
+
+                                sendV2BypassControlCommand(DEVICE_SET_NIGHT_LIGHT,
+                                        new VeSyncRequestManagedDeviceBypassV2.SetNightLight(targetNightLightMode));
+
+                                break;
+                        }
+                        break;
+                }
+            } else if (command instanceof QuantityType) {
+                switch (channelUID.getId()) {
+                    case DEVICE_CHANNEL_FAN_SPEED_ENABLED:
+                        // If the fan speed is being set enforce manual mode
+                        sendV2BypassControlCommand(DEVICE_SET_PURIFIER_MODE,
+                                new VeSyncRequestManagedDeviceBypassV2.SetMode(MODE_MANUAL), false);
+
+                        int requestedLevel = ((QuantityType<?>) command).intValue();
+                        if (requestedLevel < 1) {
+                            logger.warn("Fan speed command less than 1 - adjusting to 1 as the valid API value");
+                            requestedLevel = 1;
+                        }
+
+                        switch (deviceType) {
+                            case DEV_TYPE_CORE_600S:
+                            case DEV_TYPE_CORE_400S:
+                                if (requestedLevel > 4) {
+                                    logger.warn(
+                                            "Fan speed command greater than 4 - adjusting to 4 as the valid (Core400S) API value");
+                                    requestedLevel = 4;
+                                }
+                                break;
+                            case DEV_TYPE_CORE_200S:
+                            case DEV_TYPE_CORE_201S:
+                            case DEV_TYPE_CORE_300S:
+                                if (requestedLevel > 3) {
+                                    logger.warn(
+                                            "Fan speed command greater than 3 - adjusting to 3 as the valid (Core200S/Core300S) API value");
+                                    requestedLevel = 3;
+                                }
+                                break;
+                        }
+
+                        sendV2BypassControlCommand(DEVICE_SET_LEVEL,
+                                new VeSyncRequestManagedDeviceBypassV2.SetLevelPayload(0, DEVICE_LEVEL_TYPE_WIND,
+                                        requestedLevel));
+                        break;
+                }
+            } else if (command instanceof RefreshType) {
+                pollForUpdate();
+            } else {
+                logger.trace("UNKNOWN COMMAND: {} {}", command.getClass().toString(), channelUID);
+            }
+        });
+    }
+
+    @Override
+    protected void pollForDeviceData(final ExpiringCache<String> cachedResponse) {
+        final String deviceType = getThing().getProperties().get(DEVICE_PROP_DEVICE_TYPE);
+        if (deviceType == null) {
+            return;
+        }
+
+        switch (deviceType) {
+            case DEV_TYPE_CORE_600S:
+            case DEV_TYPE_CORE_400S:
+            case DEV_TYPE_CORE_300S:
+            case DEV_TYPE_CORE_201S:
+            case DEV_TYPE_CORE_200S:
+                processV2BypassPoll(cachedResponse);
+                break;
+            case DEV_TYPE_LV_PUR131S:
+                processV1AirPurifierPoll(cachedResponse);
+                break;
+        }
+    }
+
+    private void processV1AirPurifierPoll(final ExpiringCache<String> cachedResponse) {
+        final String deviceUuid = getThing().getProperties().get(DEVICE_PROP_DEVICE_UUID);
+        if (deviceUuid == null) {
+            return;
+        }
+
+        String response;
+        VeSyncV1AirPurifierDeviceDetailsResponse purifierStatus;
+        synchronized (pollLock) {
+            response = cachedResponse.getValue();
+            boolean cachedDataUsed = response != null;
+            if (response == null) {
+                logger.trace("Requesting fresh response");
+                response = sendV1Command("POST", "https://smartapi.vesync.com/131airPurifier/v1/device/deviceDetail",
+                        new VeSyncRequestV1ManagedDeviceDetails(deviceUuid));
+            } else {
+                logger.trace("Using cached response {}", response);
+            }
+
+            if (response.equals(EMPTY_STRING)) {
+                return;
+            }
+
+            purifierStatus = VeSyncConstants.GSON.fromJson(response, VeSyncV1AirPurifierDeviceDetailsResponse.class);
+
+            if (purifierStatus == null) {
+                return;
+            }
+
+            if (!cachedDataUsed) {
+                cachedResponse.putValue(response);
+            }
+        }
+
+        // Bail and update the status of the thing - it will be updated to online by the next search
+        // that detects it is online.
+        if (purifierStatus.isDeviceOnline()) {
+            updateStatus(ThingStatus.ONLINE);
+        } else {
+            updateStatus(ThingStatus.OFFLINE);
+            return;
+        }
+
+        if (!"0".equals(purifierStatus.getCode())) {
+            logger.warn("Check Thing type has been set - API gave a unexpected response for an Air Purifier");
+            return;
+        }
+
+        updateState(DEVICE_CHANNEL_ENABLED, OnOffType.from(MODE_ON.equals(purifierStatus.getDeviceStatus())));
+        updateState(DEVICE_CHANNEL_CHILD_LOCK_ENABLED, OnOffType.from(MODE_ON.equals(purifierStatus.getChildLock())));
+        updateState(DEVICE_CHANNEL_FAN_MODE_ENABLED, new StringType(purifierStatus.getMode()));
+        updateState(DEVICE_CHANNEL_FAN_SPEED_ENABLED, new DecimalType(String.valueOf(purifierStatus.getLevel())));
+        updateState(DEVICE_CHANNEL_DISPLAY_ENABLED, OnOffType.from(MODE_ON.equals(purifierStatus.getScreenStatus())));
+        updateState(DEVICE_CHANNEL_AIRQUALITY_BASIC, new DecimalType(purifierStatus.getAirQuality()));
+    }
+
+    private void processV2BypassPoll(final ExpiringCache<String> cachedResponse) {
+        String response;
+        VeSyncV2BypassPurifierStatus purifierStatus;
+        synchronized (pollLock) {
+            response = cachedResponse.getValue();
+            boolean cachedDataUsed = response != null;
+            if (response == null) {
+                logger.trace("Requesting fresh response");
+                response = sendV2BypassCommand(DEVICE_GET_PURIFIER_STATUS,
+                        new VeSyncRequestManagedDeviceBypassV2.EmptyPayload());
+            } else {
+                logger.trace("Using cached response {}", response);
+            }
+
+            if (response.equals(EMPTY_STRING)) {
+                return;
+            }
+
+            purifierStatus = VeSyncConstants.GSON.fromJson(response, VeSyncV2BypassPurifierStatus.class);
+
+            if (purifierStatus == null) {
+                return;
+            }
+
+            if (!cachedDataUsed) {
+                cachedResponse.putValue(response);
+            }
+        }
+
+        // Bail and update the status of the thing - it will be updated to online by the next search
+        // that detects it is online.
+        if (purifierStatus.isMsgDeviceOffline()) {
+            updateStatus(ThingStatus.OFFLINE);
+            return;
+        } else if (purifierStatus.isMsgSuccess()) {
+            updateStatus(ThingStatus.ONLINE);
+        }
+
+        if (!"0".equals(purifierStatus.result.getCode())) {
+            logger.warn("Check Thing type has been set - API gave a unexpected response for an Air Purifier");
+            return;
+        }
+
+        updateState(DEVICE_CHANNEL_ENABLED, OnOffType.from(purifierStatus.result.result.enabled));
+        updateState(DEVICE_CHANNEL_CHILD_LOCK_ENABLED, OnOffType.from(purifierStatus.result.result.childLock));
+        updateState(DEVICE_CHANNEL_DISPLAY_ENABLED, OnOffType.from(purifierStatus.result.result.display));
+        updateState(DEVICE_CHANNEL_AIR_FILTER_LIFE_PERCENTAGE_REMAINING,
+                new QuantityType<>(purifierStatus.result.result.filterLife, Units.PERCENT));
+        updateState(DEVICE_CHANNEL_FAN_MODE_ENABLED, new StringType(purifierStatus.result.result.mode));
+        updateState(DEVICE_CHANNEL_FAN_SPEED_ENABLED, new DecimalType(purifierStatus.result.result.level));
+        updateState(DEVICE_CHANNEL_ERROR_CODE, new DecimalType(purifierStatus.result.result.deviceErrorCode));
+        updateState(DEVICE_CHANNEL_AIRQUALITY_BASIC, new DecimalType(purifierStatus.result.result.airQuality));
+        updateState(DEVICE_CHANNEL_AIRQUALITY_PM25,
+                new QuantityType<>(purifierStatus.result.result.airQualityValue, Units.MICROGRAM_PER_CUBICMETRE));
+
+        updateState(DEVICE_CHANNEL_AF_CONFIG_DISPLAY_FOREVER,
+                OnOffType.from(purifierStatus.result.result.configuration.displayForever));
+
+        updateState(DEVICE_CHANNEL_AF_CONFIG_AUTO_MODE_PREF,
+                new StringType(purifierStatus.result.result.configuration.autoPreference.autoType));
+
+        updateState(DEVICE_CHANNEL_AF_CONFIG_AUTO_ROOM_SIZE,
+                new DecimalType(purifierStatus.result.result.configuration.autoPreference.roomSize));
+
+        // Only 400S appears to have this JSON extension object
+        if (purifierStatus.result.result.extension != null) {
+            if (purifierStatus.result.result.extension.timerRemain > 0) {
+                updateState(DEVICE_CHANNEL_AF_AUTO_OFF_CALC_TIME, new DateTimeType(LocalDateTime.now()
+                        .plus(purifierStatus.result.result.extension.timerRemain, ChronoUnit.SECONDS).toString()));
+            } else {
+                updateState(DEVICE_CHANNEL_AF_AUTO_OFF_CALC_TIME, new DateTimeItem("nullEnforcements").getState());
+            }
+            updateState(DEVICE_CHANNEL_AF_SCHEDULES_COUNT,
+                    new DecimalType(purifierStatus.result.result.extension.scheduleCount));
+        }
+
+        // Not applicable to 400S payload's
+        if (purifierStatus.result.result.nightLight != null) {
+            updateState(DEVICE_CHANNEL_AF_NIGHT_LIGHT, new DecimalType(purifierStatus.result.result.nightLight));
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.vesync/src/main/resources/OH-INF/binding/binding.xml
new file mode 100644 (file)
index 0000000..98c9e1c
--- /dev/null
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<binding:binding id="vesync" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
+
+       <name>VeSync Binding</name>
+       <description>This is the binding for the VeSync products. Currently, this supports the Levoit branded Air Purifiers and
+               Humidifiers using the v2 protocol.</description>
+
+</binding:binding>
diff --git a/bundles/org.openhab.binding.vesync/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.vesync/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644 (file)
index 0000000..e2b841d
--- /dev/null
@@ -0,0 +1,334 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="vesync"
+       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="bridge">
+               <label>VeSync Bridge</label>
+               <description>The VeSync bridge represents the VeSync cloud service.</description>
+
+               <properties>
+                       <property name="Registration Time"/>
+                       <property name="Country Code"/>
+                       <property name="Accept Language"/>
+               </properties>
+
+               <config-description>
+                       <parameter name="username" type="text">
+                               <context>email</context>
+                               <required>true</required>
+                               <label>Username</label>
+                               <description>Name of a registered VeSync user, that allows to access the mobile application.</description>
+                       </parameter>
+                       <parameter name="password" type="text">
+                               <context>password</context>
+                               <required>true</required>
+                               <label>Password</label>
+                               <description>Password for the registered VeSync username, that allows to access the mobile application.</description>
+                       </parameter>
+                       <parameter name="backgroundDeviceDiscovery" type="boolean">
+                               <label>Background Device Scans</label>
+                               <description>Enable background scanning for new devices.</description>
+                               <default>true</default>
+                       </parameter>
+                       <parameter name="airPurifierPollInterval" type="integer" min="5" step="1" unit="s">
+                               <label>Air Filters/Humidifiers Poll Rate</label>
+                               <description>Seconds between fetching background updates about the air purifiers / humidifiers.</description>
+                               <default>60</default>
+                       </parameter>
+               </config-description>
+       </bridge-type>
+
+       <thing-type id="airPurifier">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="bridge"/>
+               </supported-bridge-type-refs>
+
+               <label>Air Purifier via VeSync</label>
+               <description>A Air Purifier uplinking to VeSync</description>
+
+               <channels>
+                       <channel id="enabled" typeId="deviceEnabledType"/>
+                       <channel id="childLock" typeId="deviceChildLockEnabledType"/>
+                       <channel id="display" typeId="deviceDisplayEnabledType"/>
+                       <channel id="filterLifePercentage" typeId="deviceFilterLifePercentageType"/>
+                       <channel id="fanMode" typeId="airPurifierModeType"/>
+                       <channel id="manualFanSpeed" typeId="airPurifierFanLevelType"/>
+                       <channel id="errorCode" typeId="deviceErrorCodeType"/>
+                       <channel id="airQuality" typeId="deviceAirQualityBasicType"/>
+                       <channel id="airQualityPM25" typeId="airQualityPM25"/>
+                       <channel id="configDisplayForever" typeId="deviceAFConfigDisplayForever"/>
+                       <channel id="configAutoMode" typeId="deviceAFConfigAutoPrefType"/>
+                       <channel id="timerExpiry" typeId="deviceAFTimerExpiry"/>
+                       <channel id="configAutoRoomSize" typeId="deviceAFConfigAutoPrefRoomSizeType"/>
+                       <channel id="schedulesCount" typeId="deviceAFConfigAutoScheduleCountType"/>
+                       <channel id="nightLightMode" typeId="deviceAFNightLight"/>
+               </channels>
+
+               <properties>
+                       <property name="Device Name"/>
+                       <property name="Device Type"/>
+                       <property name="MAC Id"/>
+               </properties>
+               <representation-property>macId</representation-property>
+
+               <config-description>
+                       <parameter name="macId" type="text">
+                               <label>MAC Id</label>
+                               <description>The MAC Id of the device as reported by the API.</description>
+                       </parameter>
+                       <parameter name="deviceName" type="text">
+                               <label>Device Name</label>
+                               <description>The name allocated to the device by the app. (Must be unique if used)</description>
+                       </parameter>
+               </config-description>
+
+       </thing-type>
+
+       <thing-type id="airHumidifier">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="bridge"/>
+               </supported-bridge-type-refs>
+
+               <label>Air Humidifier via VeSync</label>
+               <description>A Air Humidifier uplinking to VeSync</description>
+
+               <channels>
+                       <channel id="enabled" typeId="deviceEnabledType"/>
+                       <channel id="display" typeId="deviceDisplayEnabledType"/>
+                       <channel id="waterLacking" typeId="deviceWaterLackingType"/>
+                       <channel id="humidityHigh" typeId="deviceHighHumidityType"/>
+                       <channel id="waterTankLifted" typeId="deviceWaterTankLiftedType"/>
+                       <channel id="stopAtHumiditySetpoint" typeId="deviceAutomaticStopReachTargetType"/>
+                       <channel id="humidity" typeId="deviceHumidityType"/>
+                       <channel id="mistLevel" typeId="deviceMistLevelType"/>
+                       <channel id="humidifierMode" typeId="airHumidifierModeType"/>
+                       <channel id="nightLightMode" typeId="deviceAFNightLight"/>
+                       <channel id="humiditySetpoint" typeId="deviceConfigTargetHumidity"/>
+                       <channel id="warmEnabled" typeId="warmModeEnabled"/>
+                       <channel id="warmLevel" typeId="warmLevel"/>
+               </channels>
+
+               <properties>
+                       <property name="Device Name"/>
+                       <property name="Device Type"/>
+                       <property name="MAC Id"/>
+               </properties>
+               <representation-property>macId</representation-property>
+
+               <config-description>
+                       <parameter name="macId" type="text">
+                               <label>MAC Id</label>
+                               <description>The MAC Id of the device as reported by the API.</description>
+                       </parameter>
+                       <parameter name="deviceName" type="text">
+                               <label>Device Name</label>
+                               <description>The name allocated to the device by the app. (Must be unique if used)</description>
+                       </parameter>
+               </config-description>
+
+       </thing-type>
+
+       <channel-type id="deviceEnabledType">
+               <item-type>Switch</item-type>
+               <label>Switched On</label>
+               <description>Indicator if the device is switched on</description>
+               <state readOnly="false"/>
+       </channel-type>
+
+       <channel-type id="deviceChildLockEnabledType">
+               <item-type>Switch</item-type>
+               <label>Display Lock</label>
+               <description>Indicator if the devices child lock is enabled (Display Lock)</description>
+               <state readOnly="false"/>
+       </channel-type>
+
+       <channel-type id="deviceDisplayEnabledType">
+               <item-type>Switch</item-type>
+               <label>Display</label>
+               <description>Indicator if the devices display is enabled</description>
+               <state readOnly="false"/>
+       </channel-type>
+
+       <channel-type id="deviceFilterLifePercentageType">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Filter Life Remaining</label>
+               <description>Indicator of the remaining filter life</description>
+               <state readOnly="true" pattern="%.0f %%"/>
+       </channel-type>
+
+       <channel-type id="airPurifierModeType">
+               <item-type>String</item-type>
+               <label>Operation Mode</label>
+               <description>The operating mode the air purifier is currently set to</description>
+               <state readOnly="false">
+                       <options>
+                               <option value="auto">Auto</option>
+                               <option value="manual">Manual Fan Control</option>
+                               <option value="sleep">Sleeping Auto</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="deviceAFNightLight">
+               <item-type>String</item-type>
+               <label>Night Light</label>
+               <description>The operating mode of the night light functionality</description>
+               <state readOnly="false">
+                       <options>
+                               <option value="on">On</option>
+                               <option value="dim">Dimmed</option>
+                               <option value="off">Off</option>
+                       </options>
+               </state>
+       </channel-type>
+
+
+       <channel-type id="airPurifierFanLevelType">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Fan Speed</label>
+               <description>Indicator of the current fan speed</description>
+               <state readOnly="true" pattern="%.0f"/>
+       </channel-type>
+
+       <channel-type id="deviceErrorCodeType">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Device Error Code</label>
+               <description>Indicator of the current error code of the device</description>
+               <state readOnly="true" pattern="%.0f"/>
+       </channel-type>
+
+       <channel-type id="deviceAirQualityBasicType">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Air Quality</label>
+               <description>System representation of air quality</description>
+               <state readOnly="true" pattern="%.0f"/>
+       </channel-type>
+
+       <channel-type id="airQualityPM25">
+               <item-type>Number:Density</item-type>
+               <label>Air Quality PPM2.5</label>
+               <description>Indicator of current air quality</description>
+               <state readOnly="true" pattern="%.0f %unit%"/>
+       </channel-type>
+
+       <channel-type id="deviceAFConfigDisplayForever">
+               <item-type>Switch</item-type>
+               <label>Config: Display Forever</label>
+               <description>Configuration: If the devices display is enabled forever</description>
+       </channel-type>
+
+       <channel-type id="deviceAFConfigAutoPrefType">
+               <item-type>String</item-type>
+               <label>Config: Auto Mode</label>
+               <description>The operating mode when the air purifier is set to auto</description>
+               <state readOnly="true">
+                       <options>
+                               <option value="default">Auto (Air Quality)</option>
+                               <option value="quiet">Quiet (No High Speed)</option>
+                               <option value="efficient">Auto (Room Size)</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="deviceAFTimerExpiry">
+               <item-type>DateTime</item-type>
+               <label>Auto Off Expiry</label>
+               <description>The time when the auto off timer will be reached</description>
+               <state readOnly="true" pattern="%1$tF %1$tR"/>
+       </channel-type>
+
+       <channel-type id="deviceAFConfigAutoPrefRoomSizeType">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Config: Room size</label>
+               <description>Room size (foot sq) for efficient auto mode</description>
+               <state readOnly="true" pattern="%.0f sq ft"/>
+       </channel-type>
+
+       <channel-type id="deviceAFConfigAutoScheduleCountType">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Config: Schedules Count</label>
+               <description>The current number of schedules configured</description>
+               <state readOnly="true" pattern="%.0f"/>
+       </channel-type>
+
+
+       <channel-type id="deviceWaterLackingType">
+               <item-type>Switch</item-type>
+               <label>Water Low/Empty</label>
+               <description>Indicator if the devices water is low or empty</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="deviceHighHumidityType">
+               <item-type>Switch</item-type>
+               <label>High Humidity</label>
+               <description>Indicator if the device is measuring high humidity</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="deviceWaterTankLiftedType">
+               <item-type>Switch</item-type>
+               <label>Water Tank Removed</label>
+               <description>Indicator if the device is reporting the water tank as removed</description>
+               <state readOnly="true"/>
+       </channel-type>
+
+       <channel-type id="deviceAutomaticStopReachTargetType">
+               <item-type>Switch</item-type>
+               <label>Stop @ Set Point</label>
+               <description>Indicator if the device is set to stop when the humidity set point has been reached</description>
+               <state readOnly="false"/>
+       </channel-type>
+
+       <channel-type id="deviceHumidityType">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Humidity Level</label>
+               <description>System representation of humidity</description>
+               <state readOnly="true" pattern="%.0f %unit%"/>
+       </channel-type>
+
+       <channel-type id="deviceConfigTargetHumidity">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Humidity Set Point</label>
+               <description>Humidity Set Point</description>
+               <state readOnly="false" pattern="%.0f %unit%"/>
+       </channel-type>
+
+       <channel-type id="deviceMistLevelType">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Mist Level</label>
+               <description>System representation of mist level</description>
+               <state readOnly="false" pattern="%.0f"/>
+       </channel-type>
+
+       <channel-type id="airHumidifierModeType">
+               <item-type>String</item-type>
+               <label>Operation Mode</label>
+               <description>The operating mode the air humidifier is currently set to</description>
+               <state readOnly="false">
+                       <options>
+                               <option value="auto">Auto</option>
+                               <option value="manual">Manual Control</option>
+                               <option value="sleep">Sleeping Auto</option>
+                       </options>
+               </state>
+       </channel-type>
+
+       <channel-type id="warmModeEnabled">
+               <item-type>Switch</item-type>
+               <label>Warm Mode Enabled</label>
+               <description>Indicator if the device is set to warm mist</description>
+               <state readOnly="false"/>
+       </channel-type>
+
+       <channel-type id="warmLevel">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Warm Level</label>
+               <description>Warm Level</description>
+               <state readOnly="false" pattern="%.0f"/>
+       </channel-type>
+
+
+</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncAuthenticatedRequestTest.java b/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncAuthenticatedRequestTest.java
new file mode 100644 (file)
index 0000000..9084ae8
--- /dev/null
@@ -0,0 +1,74 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.handler.requests;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.vesync.internal.exceptions.AuthenticationException;
+import org.openhab.binding.vesync.internal.VeSyncConstants;
+import org.openhab.binding.vesync.internal.dto.requests.VesyncAuthenticatedRequest;
+import org.openhab.binding.vesync.internal.dto.requests.VesyncLoginCredentials;
+import org.openhab.binding.vesync.internal.dto.responses.VesyncLoginResponse;
+
+/**
+ * The {@link VesyncLoginCredentials} class implements unit test case for {@link VesyncLoginResponse}
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class VesyncAuthenticatedRequestTest {
+
+    public final static VesyncLoginResponse.VesyncUserSession testUser = VeSyncConstants.GSON.fromJson(
+            org.openhab.binding.vesync.internal.handler.responses.VesyncLoginResponseTest.testGoodLoginResponseBody,
+            VesyncLoginResponse.class).result;
+
+    @Test
+    public void checkBaseFieldsExist() {
+        String content = VeSyncConstants.GSON.toJson(new VesyncLoginCredentials("username", "passmd5"));
+
+        assertEquals(true, content.contains("\"timeZone\": \"America/New_York\""));
+        assertEquals(true, content.contains("\"acceptLanguage\": \"en\""));
+
+        assertEquals(true, content.contains("\"appVersion\": \"2.5.1\""));
+        assertEquals(true, content.contains("\"phoneBrand\": \"SM N9005\""));
+        assertEquals(true, content.contains("\"phoneOS\": \"Android\""));
+
+        Pattern p = Pattern.compile("\"traceId\": \"\\d+\"");
+        Matcher m = p.matcher(content);
+        assertEquals(true, m.find());
+    }
+
+    @Test
+    public void checkAuthenicationData() {
+
+        // Simulate as the code flow should run - parse data and then use it
+        VesyncLoginResponse response = VeSyncConstants.GSON
+                .fromJson(org.openhab.binding.vesync.internal.handler.responses.VesyncLoginResponseTest.testGoodLoginResponseBody, VesyncLoginResponse.class);
+
+        String content = "";
+
+        try {
+            content = VeSyncConstants.GSON.toJson(new VesyncAuthenticatedRequest(response.result));
+        } catch (AuthenticationException ae) {
+
+        }
+
+        assertEquals(true, content.contains("\"token\": \"AccessTokenString=\""));
+        assertEquals(true, content.contains("\"accountID\": \"5328043\""));
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncLoginCredentialsTest.java b/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncLoginCredentialsTest.java
new file mode 100644 (file)
index 0000000..a670f91
--- /dev/null
@@ -0,0 +1,66 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.handler.requests;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.vesync.internal.VeSyncConstants;
+import org.openhab.binding.vesync.internal.api.VesyncV2ApiHelper;
+import org.openhab.binding.vesync.internal.dto.requests.VesyncLoginCredentials;
+
+/**
+ * The {@link VesyncLoginCredentials} class implements unit test case for {@link VesyncLoginResponse}
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class VesyncLoginCredentialsTest {
+
+    @Test
+    public void checkMd5Calculation() {
+        assertEquals("577441848f056cd02d4c500b25fdd76a",VesyncV2ApiHelper.calculateMd5("TestHashInPythonLib=+"));
+    }
+
+    @Test
+    public void checkBaseFieldsExist() {
+        String content = VeSyncConstants.GSON.toJson(new VesyncLoginCredentials("username", "passmd5"));
+
+        assertEquals(true, content.contains("\"timeZone\": \"America/New_York\""));
+        assertEquals(true, content.contains("\"acceptLanguage\": \"en\""));
+
+        assertEquals(true, content.contains("\"appVersion\": \"2.5.1\""));
+        assertEquals(true, content.contains("\"phoneBrand\": \"SM N9005\""));
+        assertEquals(true, content.contains("\"phoneOS\": \"Android\""));
+
+        Pattern p = Pattern.compile("\"traceId\": \"\\d+\"");
+        Matcher m = p.matcher(content);
+        assertEquals(true, m.find());
+    }
+
+    @Test
+    public void checkLoginMethodJson() {
+
+        String content = VeSyncConstants.GSON.toJson(new VesyncLoginCredentials("username", "passmd5"));
+
+        assertEquals(true, content.contains("\"method\": \"login\""));
+        assertEquals(true, content.contains("\"email\": \"username\""));
+        assertEquals(true, content.contains("\"password\": \"passmd5\""));
+        assertEquals(true, content.contains("\"userType\": \"1\""));
+        assertEquals(true, content.contains("\"devToken\": \"\""));
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncRequestManagedDeviceBypassV2Test.java b/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncRequestManagedDeviceBypassV2Test.java
new file mode 100644 (file)
index 0000000..91bf977
--- /dev/null
@@ -0,0 +1,137 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.handler.requests;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.vesync.internal.VeSyncConstants;
+import org.openhab.binding.vesync.internal.api.VesyncV2ApiHelper;
+import org.openhab.binding.vesync.internal.dto.requests.VesyncLoginCredentials;
+import org.openhab.binding.vesync.internal.dto.requests.VesyncRequestManagedDeviceBypassV2;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * The {@link VesyncLoginCredentials} class implements unit test case for {@link VesyncLoginResponse}
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class VesyncRequestManagedDeviceBypassV2Test {
+
+    @Test
+    public void checkMd5Calculation() {
+        assertEquals("577441848f056cd02d4c500b25fdd76a",VesyncV2ApiHelper.calculateMd5("TestHashInPythonLib=+"));
+    }
+
+    @Test
+    public void checkBaseFieldsExist() {
+        String content = VeSyncConstants.GSON.toJson(new VesyncRequestManagedDeviceBypassV2());
+
+        assertEquals(true, content.contains("\"method\": \"bypassV2\""));
+        assertEquals(true, content.contains("\"data\": {}"));
+    }
+
+    @Test
+    public void checkEmptyPayload() {
+        final VesyncRequestManagedDeviceBypassV2.EmptyPayload testPaylaod = new VesyncRequestManagedDeviceBypassV2.EmptyPayload();
+        final String contentTest1 = VeSyncConstants.GSON.toJson(testPaylaod);
+        assertEquals(true, contentTest1.equals("{}"));
+    }
+
+    @Test
+    public void checkSetLevelPayload() {
+        final VesyncRequestManagedDeviceBypassV2.SetLevelPayload testPaylaod = new VesyncRequestManagedDeviceBypassV2.SetLevelPayload(1,"stringval",2);
+        final String contentTest1 = VeSyncConstants.GSON.toJson(testPaylaod);
+        assertEquals(true, contentTest1.contains("\"id\": 1"));
+        assertEquals(true,contentTest1.contains("\"type\": \"stringval\""));
+        assertEquals(true,contentTest1.contains("\"level\": 2"));
+    }
+
+    @Test
+    public void checkSetChildLockPayload() {
+        final VesyncRequestManagedDeviceBypassV2.SetChildLock testPaylaod = new VesyncRequestManagedDeviceBypassV2.SetChildLock(false);
+        final String contentTest1 = VeSyncConstants.GSON.toJson(testPaylaod);
+        assertEquals(true,contentTest1.contains("\"child_lock\": false"));
+
+        testPaylaod.childLock = true;
+        final String contentTest2 = VeSyncConstants.GSON.toJson(testPaylaod);
+        assertEquals(true,contentTest2.contains("\"child_lock\": true"));
+    }
+
+    @Test
+    public void checkSetSwitchPayload() {
+        final VesyncRequestManagedDeviceBypassV2.SetSwitchPayload testPaylaod = new VesyncRequestManagedDeviceBypassV2.SetSwitchPayload(true,0);
+        final String contentTest1 = VeSyncConstants.GSON.toJson(testPaylaod);
+        assertEquals(true, contentTest1.contains("\"enabled\": true"));
+        assertEquals(true, contentTest1.contains("\"id\": 0"));
+
+        testPaylaod.enabled = false;
+        testPaylaod.id = 100;
+        final String contentTest2 = VeSyncConstants.GSON.toJson(testPaylaod);
+        assertEquals(true, contentTest2.contains("\"enabled\": false"));
+        assertEquals(true, contentTest2.contains("\"id\": 100"));
+    }
+
+    @Test
+    public void checkSetNightLightPayload() {
+        final VesyncRequestManagedDeviceBypassV2.SetNightLight testPaylaod = new VesyncRequestManagedDeviceBypassV2.SetNightLight("myValue");
+        final String contentTest1 = VeSyncConstants.GSON.toJson(testPaylaod);
+        assertEquals(true, contentTest1.contains("\"night_light\": \"myValue\""));
+    }
+
+    @Test
+    public void checkSetTargetHumidityPayload() {
+        final VesyncRequestManagedDeviceBypassV2.SetTargetHumidity test0Paylaod = new VesyncRequestManagedDeviceBypassV2.SetTargetHumidity(0);
+        final String contentTest1 = VeSyncConstants.GSON.toJson(test0Paylaod);
+        assertEquals(true, contentTest1.contains("\"target_humidity\": 0"));
+
+        final VesyncRequestManagedDeviceBypassV2.SetTargetHumidity test100Paylaod = new VesyncRequestManagedDeviceBypassV2.SetTargetHumidity(100);
+        final String contentTest2 = VeSyncConstants.GSON.toJson(test100Paylaod);
+        assertEquals(true, contentTest2.contains("\"target_humidity\": 100"));
+    }
+
+    @Test
+    public void checkSetNightLightBrightnessPayload() {
+        final VesyncRequestManagedDeviceBypassV2.SetNightLightBrightness test0Paylaod = new VesyncRequestManagedDeviceBypassV2.SetNightLightBrightness(0);
+        final String contentTest1 = VeSyncConstants.GSON.toJson(test0Paylaod);
+        assertEquals(true, contentTest1.contains("\"night_light_brightness\": 0"));
+
+        final VesyncRequestManagedDeviceBypassV2.SetNightLightBrightness test100Paylaod = new VesyncRequestManagedDeviceBypassV2.SetNightLightBrightness(100);
+        final String contentTest2 = VeSyncConstants.GSON.toJson(test100Paylaod);
+        assertEquals(true, contentTest2.contains("\"night_light_brightness\": 100"));
+    }
+
+    @Test
+    public void checkEnabledPayload() {
+        final VesyncRequestManagedDeviceBypassV2.EnabledPayload enabledOn = new VesyncRequestManagedDeviceBypassV2.EnabledPayload(true);
+        final String contentTest1 = VeSyncConstants.GSON.toJson(enabledOn);
+        assertEquals(true, contentTest1.contains("\"enabled\": true"));
+
+        final VesyncRequestManagedDeviceBypassV2.EnabledPayload enabledOff = new VesyncRequestManagedDeviceBypassV2.EnabledPayload(false);
+        final String contentTest2 = VeSyncConstants.GSON.toJson(enabledOff);
+        assertEquals(true, contentTest2.contains("\"enabled\": false"));
+    }
+
+    @Test
+    public void checkLoginMethodJson() {
+
+        String content = VeSyncConstants.GSON.toJson(new VesyncLoginCredentials("username", "passmd5"));
+
+        assertEquals(true, content.contains("\"method\": \"login\""));
+        assertEquals(true, content.contains("\"email\": \"username\""));
+        assertEquals(true, content.contains("\"password\": \"passmd5\""));
+        assertEquals(true, content.contains("\"userType\": \"1\""));
+        assertEquals(true, content.contains("\"devToken\": \"\""));
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncRequestManagedDevicesPageTest.java b/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncRequestManagedDevicesPageTest.java
new file mode 100644 (file)
index 0000000..3263cef
--- /dev/null
@@ -0,0 +1,70 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.handler.requests;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.vesync.internal.exceptions.AuthenticationException;
+import org.openhab.binding.vesync.internal.VeSyncConstants;
+import org.openhab.binding.vesync.internal.dto.requests.VesyncLoginCredentials;
+import org.openhab.binding.vesync.internal.dto.requests.VesyncRequestManagedDevicesPage;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * The {@link VesyncRequestManagedDevicesPageTest} class implements unit test case for
+ * {@link VesyncRequestManagedDevicesPage}
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class VesyncRequestManagedDevicesPageTest {
+
+    @Test
+    public void checkBaseFieldsExist() {
+        String content = VeSyncConstants.GSON.toJson(new VesyncLoginCredentials("username", "passmd5"));
+
+        assertEquals(true, content.contains("\"timeZone\": \"America/New_York\""));
+        assertEquals(true, content.contains("\"acceptLanguage\": \"en\""));
+
+        assertEquals(true, content.contains("\"appVersion\": \"2.5.1\""));
+        assertEquals(true, content.contains("\"phoneBrand\": \"SM N9005\""));
+        assertEquals(true, content.contains("\"phoneOS\": \"Android\""));
+
+        Pattern p = Pattern.compile("\"traceId\": \"\\d+\"");
+        Matcher m = p.matcher(content);
+        assertEquals(true, m.find());
+    }
+
+    @Test
+    public void checkRequestDevicesFields() {
+
+        String content = "";
+        try {
+            content = VeSyncConstants.GSON
+                    .toJson(new VesyncRequestManagedDevicesPage(org.openhab.binding.vesync.internal.handler.requests.VesyncAuthenticatedRequestTest.testUser, 1, 100));
+        } catch (AuthenticationException ae) {
+
+        }
+
+
+        assertEquals(true, content.contains("\"method\": \"devices\""));
+        assertEquals(true, content.contains("\"pageNo\": \"1\""));
+        assertEquals(true, content.contains("\"pageSize\": \"100\""));
+        assertEquals(true, content.contains("\"token\": \"AccessTokenString=\""));
+        assertEquals(true, content.contains("\"accountID\": \"5328043\""));
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncRequestTest.java b/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncRequestTest.java
new file mode 100644 (file)
index 0000000..c72fc20
--- /dev/null
@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) 2010-2022 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 java.org.openhab.binding.vesync.internal.handler.requests;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.vesync.internal.VeSyncConstants;
+import org.openhab.binding.vesync.internal.dto.requests.VesyncRequest;
+
+/**
+ * The {@link VesyncRequestTest} class implements unit test case for {@link VesyncRequest}
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class VesyncRequestTest {
+
+    @Test
+    public void checkBaseFieldsExist() {
+        String content = VeSyncConstants.GSON.toJson(new VesyncRequest());
+
+        assertEquals(true, content.contains("\"timeZone\": \"America/New_York\""));
+        assertEquals(true, content.contains("\"acceptLanguage\": \"en\""));
+
+        assertEquals(true, content.contains("\"appVersion\": \"2.5.1\""));
+        assertEquals(true, content.contains("\"phoneBrand\": \"SM N9005\""));
+        assertEquals(true, content.contains("\"phoneOS\": \"Android\""));
+
+        Pattern p = Pattern.compile("\"traceId\": \"\\d+\"");
+        Matcher m = p.matcher(content);
+        assertEquals(true, m.find());
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncRequestV1ManagedDeviceDetailsTest.java b/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncRequestV1ManagedDeviceDetailsTest.java
new file mode 100644 (file)
index 0000000..420fc4b
--- /dev/null
@@ -0,0 +1,72 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.handler.requests;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.vesync.internal.exceptions.AuthenticationException;
+import org.openhab.binding.vesync.internal.VeSyncConstants;
+import org.openhab.binding.vesync.internal.dto.requests.VesyncLoginCredentials;
+import org.openhab.binding.vesync.internal.dto.requests.VesyncRequestManagedDevicesPage;
+import org.openhab.binding.vesync.internal.dto.requests.VesyncRequestV1ManagedDeviceDetails;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * The {@link VesyncRequestV1ManagedDeviceDetails} class implements unit test case for
+ * {@link VesyncRequestManagedDevicesPage}
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class VesyncRequestV1ManagedDeviceDetailsTest {
+
+    // Verified content URLS
+    // https://smartapi.vesync.com/131airPurifier/v1/device/deviceDetail
+
+    @Test
+    public void checkBaseFieldsExist() {
+        String content = VeSyncConstants.GSON.toJson(new VesyncLoginCredentials("username", "passmd5"));
+
+        assertEquals(true, content.contains("\"timeZone\": \"America/New_York\""));
+        assertEquals(true, content.contains("\"acceptLanguage\": \"en\""));
+
+        assertEquals(true, content.contains("\"appVersion\": \"2.5.1\""));
+        assertEquals(true, content.contains("\"phoneBrand\": \"SM N9005\""));
+        assertEquals(true, content.contains("\"phoneOS\": \"Android\""));
+
+        Pattern p = Pattern.compile("\"traceId\": \"\\d+\"");
+        Matcher m = p.matcher(content);
+        assertEquals(true, m.find());
+    }
+
+    @Test
+    public void checkRequestDevicesFields() {
+
+        String content = "";
+        try {
+            content = VeSyncConstants.GSON
+                    .toJson(new VesyncRequestV1ManagedDeviceDetails(VesyncAuthenticatedRequestTest.testUser, "MyDeviceUUID"));
+        } catch (AuthenticationException ae) {
+
+        }
+
+        assertEquals(true, content.contains("\"uuid\": \"MyDeviceUUID\""));
+        assertEquals(true, content.contains("\"mobileId\": \"1234567890123456\""));
+        assertEquals(true, content.contains("\"token\": \"AccessTokenString=\""));
+        assertEquals(true, content.contains("\"accountID\": \"5328043\""));
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/responses/VesyncLoginResponseTest.java b/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/responses/VesyncLoginResponseTest.java
new file mode 100644 (file)
index 0000000..5523c10
--- /dev/null
@@ -0,0 +1,81 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.handler.responses;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.vesync.internal.VeSyncConstants;
+import org.openhab.binding.vesync.internal.dto.responses.VesyncLoginResponse;
+
+/**
+ * The {@link VesyncLoginResponseTest} class implements unit test case for {@link VesyncLoginResponse}
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class VesyncLoginResponseTest {
+
+    public final static String testGoodLoginResponseBody = "{\r\n" + "    \"traceId\": \"1634253816\",\r\n"
+            + "    \"code\": 0,\r\n" + "    \"msg\": \"request success\",\r\n" + "    \"result\": {\r\n"
+            + "        \"isRequiredVerify\": true,\r\n" + "        \"accountID\": \"5328043\",\r\n"
+            + "        \"avatarIcon\": \"https://image.vesync.com/defaultImages/user/avatar_nor.png\",\r\n"
+            + "        \"birthday\": \"\",\r\n" + "        \"gender\": \"\",\r\n"
+            + "        \"acceptLanguage\": \"en\",\r\n" + "        \"userType\": \"1\",\r\n"
+            + "        \"nickName\": \"david.goodyear\",\r\n" + "        \"mailConfirmation\": true,\r\n"
+            + "        \"termsStatus\": true,\r\n" + "        \"gdprStatus\": true,\r\n"
+            + "        \"countryCode\": \"GB\",\r\n" + "        \"registerAppVersion\": \"VeSync 3.1.37 build3\",\r\n"
+            + "        \"registerTime\": \"2021-10-14 17:35:50\",\r\n"
+            + "        \"verifyEmail\": \"david.goodyear@gmail.com\",\r\n" + "        \"heightCm\": 0.0,\r\n"
+            + "        \"weightTargetSt\": 0.0,\r\n" + "        \"heightUnit\": \"FT\",\r\n"
+            + "        \"heightFt\": 0.0,\r\n" + "        \"weightTargetKg\": 0.0,\r\n"
+            + "        \"weightTargetLb\": 0.0,\r\n" + "        \"weightUnit\": \"LB\",\r\n"
+            + "        \"targetBfr\": 0.0,\r\n" + "        \"displayFlag\": [],\r\n"
+            + "        \"real_weight_kg\": 0.0,\r\n" + "        \"real_weight_lb\": 0.0,\r\n"
+            + "        \"real_weight_unit\": \"lb\",\r\n" + "        \"heart_rate_zones\": 0.0,\r\n"
+            + "        \"run_step_long_cm\": 0.0,\r\n" + "        \"walk_step_long_cm\": 0.0,\r\n"
+            + "        \"step_target\": 0.0,\r\n" + "        \"sleep_target_mins\": 0.0,\r\n"
+            + "        \"token\": \"AccessTokenString=\"\r\n" + "    }\r\n" + "}";
+
+    @Test
+    public void testParseLoginGoodResponse() {
+        VesyncLoginResponse response = VeSyncConstants.GSON.fromJson(testGoodLoginResponseBody,
+                VesyncLoginResponse.class);
+        if (response != null) {
+            assertEquals("1634253816", response.getTraceId());
+            assertEquals("AccessTokenString=", response.result.token);
+            assertEquals("request success", response.msg);
+            assertEquals("5328043", response.result.accountId);
+            assertEquals("VeSync 3.1.37 build3", response.result.registerAppVersion);
+            assertEquals("GB", response.result.countryCode);
+            assertTrue(response.isMsgSuccess());
+        } else {
+            fail("GSON returned null");
+        }
+    }
+
+    @Test
+    public void testParseLoginFailResponse() {
+        String testReponse = "{\r\n" + "    \"traceId\": \"1634253816\",\r\n" + "    \"code\": -11201022,\r\n"
+                + "    \"msg\": \"password incorrect\",\r\n" + "    \"result\": null\r\n" + "}";
+        VesyncLoginResponse response = VeSyncConstants.GSON.fromJson(testReponse,
+                VesyncLoginResponse.class);
+        if (response != null) {
+            assertEquals("password incorrect", response.msg);
+            assertFalse(response.isMsgSuccess());
+        } else {
+            fail("GSON returned null");
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/responses/VesyncManagedDevicesPageTest.java b/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/responses/VesyncManagedDevicesPageTest.java
new file mode 100644 (file)
index 0000000..0b5f0ad
--- /dev/null
@@ -0,0 +1,103 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.handler.responses;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.vesync.internal.VeSyncConstants;
+import org.openhab.binding.vesync.internal.dto.responses.VesyncLoginResponse;
+import org.openhab.binding.vesync.internal.dto.responses.VesyncManagedDevicesPage;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * The {@link VesyncManagedDevicesPageTest} class implements unit test case for {@link VesyncLoginResponse}
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class VesyncManagedDevicesPageTest {
+
+    public final static String testGoodSearchResponsePageBody = "{\n" +
+            "    \"traceId\": \"1634387642\",\n" +
+            "    \"code\": 0,\n" +
+            "    \"msg\": \"request success\",\n" +
+            "    \"result\": {\n" +
+            "        \"total\": 2,\n" +
+            "        \"pageSize\": 100,\n" +
+            "        \"pageNo\": 1,\n" +
+            "        \"list\": [\n" +
+            "            {\n" +
+            "                \"deviceRegion\": \"EU\",\n" +
+            "                \"isOwner\": true,\n" +
+            "                \"authKey\": null,\n" +
+            "                \"deviceName\": \"Air Filter\",\n" +
+            "                \"deviceImg\": \"https://image.vesync.com/defaultImages/Core_400S_Series/icon_core400s_purifier_160.png\",\n" +
+            "                \"cid\": \"cidValue1\",\n" +
+            "                \"deviceStatus\": \"on\",\n" +
+            "                \"connectionStatus\": \"online\",\n" +
+            "                \"connectionType\": \"WiFi+BTOnboarding+BTNotify\",\n" +
+            "                \"deviceType\": \"Core400S\",\n" +
+            "                \"type\": \"wifi-air\",\n" +
+            "                \"uuid\": \"abcdefab-1234-1234-abcd-123498761234\",\n" +
+            "                \"configModule\": \"WiFiBTOnboardingNotify_AirPurifier_Core400S_EU\",\n" +
+            "                \"macID\": \"ab:cd:ef:12:34:56\",\n" +
+            "                \"mode\": \"simModeData\",\n" +
+            "                \"speed\": 4,\n" +
+            "                \"extension\": {\n" +
+            "                    \"airQuality\": -1,\n" +
+            "                    \"airQualityLevel\": 1,\n" +
+            "                    \"mode\": \"auto\",\n" +
+            "                    \"fanSpeedLevel\": \"1\"\n" +
+            "                },\n" +
+            "                \"currentFirmVersion\": null,\n" +
+            "                \"subDeviceNo\": \"simSubDevice\",\n" +
+            "                \"subDeviceType\": \"simSubDeviceType\",\n" +
+            "                \"deviceFirstSetupTime\": \"Oct 15, 2021 3:43:02 PM\"\n" +
+            "            }\n" +
+            "        ]\n" +
+            "    }\n" +
+            "}";
+
+    @Test
+    public void testParseManagedDevicesSearchGoodResponse() {
+        VesyncManagedDevicesPage response = VeSyncConstants.GSON.fromJson(testGoodSearchResponsePageBody,
+                VesyncManagedDevicesPage.class);
+        if (response != null) {
+            assertEquals("1634387642", response.getTraceId());
+            assertEquals("1", response.result.getPageNo());
+            assertEquals("100", response.result.getPageSize());
+            assertEquals("2", response.result.getTotal());
+            assertEquals("1", String.valueOf(response.result.list.length));
+
+            assertEquals("EU", response.result.list[0].getDeviceRegion());
+            assertEquals("Air Filter", response.result.list[0].getDeviceName());
+            assertEquals("https://image.vesync.com/defaultImages/Core_400S_Series/icon_core400s_purifier_160.png", response.result.list[0].getDeviceImg());
+            assertEquals("on", response.result.list[0].getDeviceStatus());
+            assertEquals("online", response.result.list[0].getConnectionStatus());
+            assertEquals("WiFi+BTOnboarding+BTNotify", response.result.list[0].getConnectionType());
+            assertEquals("Core400S", response.result.list[0].getDeviceType());
+            assertEquals("wifi-air", response.result.list[0].getType());
+            assertEquals("abcdefab-1234-1234-abcd-123498761234", response.result.list[0].getUuid());
+            assertEquals("WiFiBTOnboardingNotify_AirPurifier_Core400S_EU", response.result.list[0].getConfigModule());
+            assertEquals("simModeData",response.result.list[0].getMode());
+            assertEquals("simSubDevice", response.result.list[0].getSubDeviceNo());
+            assertEquals("simSubDeviceType", response.result.list[0].getSubDeviceType());
+            assertEquals( "4", response.result.list[0].getSpeed());
+            assertEquals("cidValue1",response.result.list[0].getCid());
+            assertEquals("ab:cd:ef:12:34:56", response.result.list[0].getMacId());
+        } else {
+            fail("GSON returned null");
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/responses/VesyncResponseTest.java b/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/responses/VesyncResponseTest.java
new file mode 100644 (file)
index 0000000..caee4f9
--- /dev/null
@@ -0,0 +1,55 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.handler.responses;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.Test;
+import org.openhab.binding.vesync.internal.VeSyncConstants;
+import org.openhab.binding.vesync.internal.dto.responses.VesyncResponse;
+
+/**
+ * The {@link VesyncResponseTest} class implements unit test case for {@link VesyncResponse}
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class VesyncResponseTest {
+
+    @Test
+    public void checkBaseFields() {
+        String baseTestResponse = "{\"traceId\":\"1234569876\",\r\n\"code\": 142,\r\n\"msg\": \"Response Text\"\r\n}";
+        VesyncResponse response = VeSyncConstants.GSON.fromJson(baseTestResponse, VesyncResponse.class);
+        if (response != null) {
+            assertEquals("1234569876", response.getTraceId());
+            assertEquals("142", response.getCode());
+            assertEquals("Response Text", response.msg);
+            assertEquals(false, response.isMsgSuccess());
+        } else {
+            fail("GSON returned null");
+        }
+    }
+
+    @Test
+    public void checkResponseSuccessMsg() {
+        String baseTestResponse = "{\"traceId\":\"1234569876\",\r\n\"code\": 142,\r\n\"msg\": \"request success\"\r\n}";
+        VesyncResponse response = VeSyncConstants.GSON.fromJson(baseTestResponse, VesyncResponse.class);
+        if (response != null) {
+            assertEquals(true, response.isMsgSuccess());
+        } else {
+            fail("GSON returned null");
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/responses/v1/VesyncV1AirPurifierDeviceDetailsTest.java b/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/responses/v1/VesyncV1AirPurifierDeviceDetailsTest.java
new file mode 100644 (file)
index 0000000..793a990
--- /dev/null
@@ -0,0 +1,68 @@
+/**
+ * Copyright (c) 2010-2022 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.vesync.internal.handler.responses.v1;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.vesync.internal.VeSyncConstants;
+import org.openhab.binding.vesync.internal.dto.responses.VesyncLoginResponse;
+import org.openhab.binding.vesync.internal.dto.responses.v1.VesyncV1AirPurifierDeviceDetailsResponse;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * The {@link VesyncV1AirPurifierDeviceDetailsTest} class implements unit test case for {@link VesyncLoginResponse}
+ *
+ * @author David Goodyear - Initial contribution
+ */
+@NonNullByDefault
+public class VesyncV1AirPurifierDeviceDetailsTest {
+
+    public final static String testAirPurifierResponseBasedOnCore400S = "{\n" +
+            "    \"code\": 0,\n" +
+            "    \"msg\": \"request success\",\n" +
+            "    \"traceId\": \"1634255391\",\n" +
+            "    \"screenStatus\": \"on1\",\n" +
+            "    \"airQuality\": 1,\n" +
+            "    \"level\": 2,\n" +
+            "    \"mode\": \"manual\",\n" +
+            "    \"deviceName\": \"Lounge Air Purifier\",\n" +
+            "    \"currentFirmVersion\": \"1.0.17\",\n" +
+            "    \"childLock\": \"off1\",\n" +
+            "    \"deviceStatus\": \"on2\",\n" +
+            "    \"deviceImg\": \"https://image.vesync.com/defaultImages/Core_400S_Series/icon_core400s_purifier_160.png\",\n" +
+            "    \"connectionStatus\": \"online\"\n" +
+            "}";
+
+    @Test
+    public void testParseV1AirPurifierDeviceDetailsResponse() {
+        VesyncV1AirPurifierDeviceDetailsResponse response = VeSyncConstants.GSON.fromJson(testAirPurifierResponseBasedOnCore400S,
+                VesyncV1AirPurifierDeviceDetailsResponse.class);
+
+        if (response != null) {
+            assertEquals("on1", response.getScreenStatus());
+            assertEquals(1, response.getAirQuality());
+            assertEquals(2, response.getLevel());
+            assertEquals("manual", response.getMode());
+            assertEquals("Lounge Air Purifier", response.getDeviceName());
+            assertEquals("1.0.17", response.getCurrentFirmVersion());
+            assertEquals("off1", response.getChildLock());
+            assertEquals("on2", response.getDeviceStatus());
+            assertEquals("https://image.vesync.com/defaultImages/Core_400S_Series/icon_core400s_purifier_160.png", response.getDeviceImgUrl());
+            assertEquals("online", response.getConnectionStatus());
+        } else {
+            fail("GSON returned null");
+        }
+    }
+}
index 2e778a98d4e408cca54a880528457e055452d427..c2274f6475a5ec6e08bdfc9217f07da6f6e8ac19 100644 (file)
     <module>org.openhab.binding.venstarthermostat</module>
     <module>org.openhab.binding.ventaair</module>
     <module>org.openhab.binding.verisure</module>
+    <module>org.openhab.binding.vesync</module>
     <module>org.openhab.binding.vigicrues</module>
     <module>org.openhab.binding.vitotronic</module>
     <module>org.openhab.binding.volvooncall</module>