diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index 35085730c71..e4b65e93f82 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -1681,6 +1681,11 @@
org.openhab.binding.verisure
${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.binding.vesync
+ ${project.version}
+
org.openhab.addons.bundles
org.openhab.binding.vigicrues
diff --git a/bundles/org.openhab.binding.vesync/NOTICE b/bundles/org.openhab.binding.vesync/NOTICE
new file mode 100644
index 00000000000..38d625e3492
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/NOTICE
@@ -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
index 00000000000..c8b0742d5bb
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/README.md
@@ -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="", password="", airPurifierPollInterval=60] {
+ airPurifier loungeAirFilter [deviceName=""]
+ airPurifier bedroomAirFilter [deviceName=""]
+ airHumidifier loungeHumidifier [deviceName=""]
+}
+```
+
+### 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
index 00000000000..2e3e1b7d89d
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/pom.xml
@@ -0,0 +1,17 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 3.3.0-SNAPSHOT
+
+
+ org.openhab.binding.vesync
+
+ openHAB Add-ons :: Bundles :: VeSync Binding
+
+
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
index 00000000000..3d64763fb49
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/feature/feature.xml
@@ -0,0 +1,9 @@
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ mvn:org.openhab.addons.bundles/org.openhab.binding.vesync/${project.version}
+
+
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
index 00000000000..4cd3edbdbc0
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/VeSyncBridgeConfiguration.java
@@ -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
index 00000000000..137bc9a619b
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/VeSyncConstants.java
@@ -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
index 00000000000..be355f4927b
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/VeSyncDeviceConfiguration.java
@@ -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
index 00000000000..fa660963b10
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/VeSyncHandlerFactory.java
@@ -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 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
index 00000000000..c216004509b
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/api/IHttpClientProvider.java
@@ -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
index 00000000000..1782a630a2f
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/api/VeSyncV2ApiHelper.java
@@ -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 macLookup;
+
+ public VeSyncV2ApiHelper() {
+ macLookup = new HashMap<>();
+ }
+
+ public Map 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 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
index 00000000000..977ca3c46b6
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/discovery/DeviceMetaDataUpdatedHandler.java
@@ -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
index 00000000000..a601a14d397
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/discovery/VeSyncDiscoveryService.java
@@ -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 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 getSupportedThingTypes() {
+ return SUPPORTED_THING_TYPES;
+ }
+
+ @Override
+ public void activate() {
+ final Map 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 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 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
index 00000000000..9cb73a88858
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/VeSyncBridgeConfiguration.java
@@ -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
index 00000000000..45f0f3f4862
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncAuthenticatedRequest.java
@@ -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
index 00000000000..21cb5327197
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncLoginCredentials.java
@@ -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
index 00000000000..02078cc940d
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncProtocolConstants.java
@@ -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
index 00000000000..3b693c807da
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncRequest.java
@@ -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
index 00000000000..569203ec8d9
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncRequestManagedDeviceBypassV2.java
@@ -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
index 00000000000..fa92d21f308
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncRequestManagedDevicesPage.java
@@ -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
index 00000000000..5a2a950cacf
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/requests/VeSyncRequestV1ManagedDeviceDetails.java
@@ -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
index 00000000000..fef91fd0a69
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncLoginResponse.java
@@ -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
index 00000000000..7f0823adc2a
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncManagedDeviceBase.java
@@ -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
index 00000000000..0505715d2a5
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncManagedDevicesPage.java
@@ -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
index 00000000000..1370a630454
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncResponse.java
@@ -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
index 00000000000..d540f08ee34
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncResponseManagedDeviceBypassV2.java
@@ -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
index 00000000000..2b69ba6e103
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncUserSession.java
@@ -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
index 00000000000..6c0a41a0ad3
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncV2BypassHumidifierStatus.java
@@ -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
index 00000000000..ce883674d9e
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/VeSyncV2BypassPurifierStatus.java
@@ -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
index 00000000000..b907caa17c2
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/dto/responses/v1/VeSyncV1AirPurifierDeviceDetailsResponse.java
@@ -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
index 00000000000..5b9d6ddd1fa
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/exceptions/AuthenticationException.java
@@ -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
index 00000000000..783ff3f9f33
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/exceptions/DeviceUnknownException.java
@@ -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
index 00000000000..681c4fe597e
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/handlers/VeSyncBaseDeviceHandler.java
@@ -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 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 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 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 getMetadataProperities(final @Nullable VeSyncManagedDeviceBase metadata) {
+ if (metadata == null) {
+ return Map.of();
+ }
+ final Map 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 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
index 00000000000..6294dccae1d
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/handlers/VeSyncBridgeHandler.java
@@ -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 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> 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 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
index 00000000000..2eb1c5ab52d
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/handlers/VeSyncClient.java
@@ -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
index 00000000000..30ca49fb0f4
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/handlers/VeSyncDeviceAirHumidifierHandler.java
@@ -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 CLASSIC_300S_600S_MODES = Arrays.asList(MODE_AUTO, MODE_MANUAL, MODE_SLEEP);
+ private static final List CLASSIC_300S_NIGHT_LIGHT_MODES = Arrays.asList(MODE_ON, MODE_DIM, MODE_OFF);
+
+ public static final List 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 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 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
index 00000000000..2d02cc7ceb1
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/java/org/openhab/binding/vesync/internal/handlers/VeSyncDeviceAirPurifierHandler.java
@@ -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 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 CORE_400S600S_FAN_MODES = Arrays.asList(MODE_AUTO, MODE_MANUAL, MODE_SLEEP);
+ private static final List CORE_200S300S_FAN_MODES = Arrays.asList(MODE_MANUAL, MODE_SLEEP);
+ private static final List 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 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 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 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 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
index 00000000000..98c9e1c4082
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/resources/OH-INF/binding/binding.xml
@@ -0,0 +1,10 @@
+
+
+
+ VeSync Binding
+ This is the binding for the VeSync products. Currently, this supports the Levoit branded Air Purifiers and
+ Humidifiers using the v2 protocol.
+
+
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
index 00000000000..e2b841d63f1
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/resources/OH-INF/thing/thing-types.xml
@@ -0,0 +1,334 @@
+
+
+
+
+
+ The VeSync bridge represents the VeSync cloud service.
+
+
+
+
+
+
+
+
+
+ email
+ true
+
+ Name of a registered VeSync user, that allows to access the mobile application.
+
+
+ password
+ true
+
+ Password for the registered VeSync username, that allows to access the mobile application.
+
+
+
+ Enable background scanning for new devices.
+ true
+
+
+
+ Seconds between fetching background updates about the air purifiers / humidifiers.
+ 60
+
+
+
+
+
+
+
+
+
+
+ A Air Purifier uplinking to VeSync
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ macId
+
+
+
+
+ The MAC Id of the device as reported by the API.
+
+
+
+ The name allocated to the device by the app. (Must be unique if used)
+
+
+
+
+
+
+
+
+
+
+
+ A Air Humidifier uplinking to VeSync
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ macId
+
+
+
+
+ The MAC Id of the device as reported by the API.
+
+
+
+ The name allocated to the device by the app. (Must be unique if used)
+
+
+
+
+
+
+ Switch
+
+ Indicator if the device is switched on
+
+
+
+
+ Switch
+
+ Indicator if the devices child lock is enabled (Display Lock)
+
+
+
+
+ Switch
+
+ Indicator if the devices display is enabled
+
+
+
+
+ Number:Dimensionless
+
+ Indicator of the remaining filter life
+
+
+
+
+ String
+
+ The operating mode the air purifier is currently set to
+
+
+
+
+
+
+
+
+
+
+ String
+
+ The operating mode of the night light functionality
+
+
+
+
+
+
+
+
+
+
+
+ Number:Dimensionless
+
+ Indicator of the current fan speed
+
+
+
+
+ Number:Dimensionless
+
+ Indicator of the current error code of the device
+
+
+
+
+ Number:Dimensionless
+
+ System representation of air quality
+
+
+
+
+ Number:Density
+
+ Indicator of current air quality
+
+
+
+
+ Switch
+
+ Configuration: If the devices display is enabled forever
+
+
+
+ String
+
+ The operating mode when the air purifier is set to auto
+
+
+
+
+
+
+
+
+
+
+ DateTime
+
+ The time when the auto off timer will be reached
+
+
+
+
+ Number:Dimensionless
+
+ Room size (foot sq) for efficient auto mode
+
+
+
+
+ Number:Dimensionless
+
+ The current number of schedules configured
+
+
+
+
+
+ Switch
+
+ Indicator if the devices water is low or empty
+
+
+
+
+ Switch
+
+ Indicator if the device is measuring high humidity
+
+
+
+
+ Switch
+
+ Indicator if the device is reporting the water tank as removed
+
+
+
+
+ Switch
+
+ Indicator if the device is set to stop when the humidity set point has been reached
+
+
+
+
+ Number:Dimensionless
+
+ System representation of humidity
+
+
+
+
+ Number:Dimensionless
+
+ Humidity Set Point
+
+
+
+
+ Number:Dimensionless
+
+ System representation of mist level
+
+
+
+
+ String
+
+ The operating mode the air humidifier is currently set to
+
+
+
+
+
+
+
+
+
+
+ Switch
+
+ Indicator if the device is set to warm mist
+
+
+
+
+ Number:Dimensionless
+
+ Warm Level
+
+
+
+
+
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
index 00000000000..9084ae88067
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncAuthenticatedRequestTest.java
@@ -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
index 00000000000..a670f91484f
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncLoginCredentialsTest.java
@@ -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
index 00000000000..91bf977446b
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncRequestManagedDeviceBypassV2Test.java
@@ -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
index 00000000000..3263ceffe18
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncRequestManagedDevicesPageTest.java
@@ -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
index 00000000000..c72fc20ac2e
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncRequestTest.java
@@ -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
index 00000000000..420fc4b1958
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/requests/VesyncRequestV1ManagedDeviceDetailsTest.java
@@ -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
index 00000000000..5523c10f371
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/responses/VesyncLoginResponseTest.java
@@ -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
index 00000000000..0b5f0ada9c4
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/responses/VesyncManagedDevicesPageTest.java
@@ -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
index 00000000000..caee4f9bf70
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/responses/VesyncResponseTest.java
@@ -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
index 00000000000..793a990afc4
--- /dev/null
+++ b/bundles/org.openhab.binding.vesync/src/main/test/org/openhab/binding/vesync/internal/handler/responses/v1/VesyncV1AirPurifierDeviceDetailsTest.java
@@ -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");
+ }
+ }
+}
diff --git a/bundles/pom.xml b/bundles/pom.xml
index 2e778a98d4e..c2274f6475a 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -370,6 +370,7 @@
org.openhab.binding.venstarthermostat
org.openhab.binding.ventaair
org.openhab.binding.verisure
+ org.openhab.binding.vesync
org.openhab.binding.vigicrues
org.openhab.binding.vitotronic
org.openhab.binding.volvooncall